From 0a888a6c03e2d18bd4f2fc093177b03bed68a81a Mon Sep 17 00:00:00 2001 From: Thomas May Date: Sun, 24 Nov 2024 02:37:11 +0000 Subject: [PATCH 01/16] Initial sheets implementation --- UniSky/Controls/Sheet/SheetHostControl.xaml | 75 ++++++++++++++ .../Controls/Sheet/SheetHostControl.xaml.cs | 68 +++++++++++++ UniSky/Controls/Sheet/SheetRootControl.xaml | 97 +++++++++++++++++++ .../Controls/Sheet/SheetRootControl.xaml.cs | 60 ++++++++++++ UniSky/Helpers/Composition/BirdAnimation.cs | 2 +- UniSky/Package.appxmanifest | 2 +- UniSky/Pages/FeedsPage.xaml | 3 +- UniSky/Pages/FeedsPage.xaml.cs | 5 +- UniSky/Pages/LoginPage.xaml.cs | 5 + UniSky/Pages/ProfilePage.xaml | 1 - UniSky/Pages/ProfilePage.xaml.cs | 16 ++- UniSky/RootPage.xaml | 6 +- UniSky/RootPage.xaml.cs | 2 +- UniSky/UniSky.csproj | 14 +++ UniSky/ViewModels/FeedsViewModel.cs | 8 +- 15 files changed, 353 insertions(+), 11 deletions(-) create mode 100644 UniSky/Controls/Sheet/SheetHostControl.xaml create mode 100644 UniSky/Controls/Sheet/SheetHostControl.xaml.cs create mode 100644 UniSky/Controls/Sheet/SheetRootControl.xaml create mode 100644 UniSky/Controls/Sheet/SheetRootControl.xaml.cs diff --git a/UniSky/Controls/Sheet/SheetHostControl.xaml b/UniSky/Controls/Sheet/SheetHostControl.xaml new file mode 100644 index 0000000..c9fddf4 --- /dev/null +++ b/UniSky/Controls/Sheet/SheetHostControl.xaml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/UniSky/Controls/Sheet/SheetHostControl.xaml.cs b/UniSky/Controls/Sheet/SheetHostControl.xaml.cs new file mode 100644 index 0000000..f622977 --- /dev/null +++ b/UniSky/Controls/Sheet/SheetHostControl.xaml.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices.WindowsRuntime; +using CommunityToolkit.Mvvm.DependencyInjection; +using Microsoft.Toolkit.Uwp.UI.Extensions; +using UniSky.Services; +using Windows.Foundation; +using Windows.Foundation.Collections; +using Windows.UI.Xaml; +using Windows.UI.Xaml.Controls; +using Windows.UI.Xaml.Controls.Primitives; +using Windows.UI.Xaml.Data; +using Windows.UI.Xaml.Input; +using Windows.UI.Xaml.Media; +using Windows.UI.Xaml.Navigation; +using MUXC = Microsoft.UI.Xaml.Controls; + +// The User Control item template is documented at https://go.microsoft.com/fwlink/?LinkId=234236 + +namespace UniSky.Controls.Sheet +{ + public sealed partial class SheetHostControl : UserControl + { + public SheetHostControl() + { + this.InitializeComponent(); + } + + private void OnLoaded(object sender, RoutedEventArgs e) + { + var safeAreaService = Ioc.Default.GetRequiredService(); + safeAreaService.SafeAreaUpdated += OnSafeAreaUpdated; + } + + private void OnSafeAreaUpdated(object sender, SafeAreaUpdatedEventArgs e) + { + SheetScrollViewer.Padding = new Thickness(0, 16 + e.SafeArea.Bounds.Top, 0, 0); + RootGrid.Height = ActualHeight - (SheetScrollViewer.Padding.Top + SheetScrollViewer.Padding.Bottom); + } + + protected override Size ArrangeOverride(Size finalSize) + { + RootGrid.Height = finalSize.Height - (SheetScrollViewer.Padding.Top + SheetScrollViewer.Padding.Bottom); + + return base.ArrangeOverride(finalSize); + } + + private void Button_Click(object sender, RoutedEventArgs e) + { + this.FindParent() + .HideSheet(); + } + + internal void Navigate(Type type, object parameter = null) + { + SheetContentFrame.Navigate(type, parameter); + } + + private void RefreshContainer_RefreshRequested(MUXC.RefreshContainer sender, MUXC.RefreshRequestedEventArgs args) + { + this.FindParent() + .HideSheet(); + } + } +} diff --git a/UniSky/Controls/Sheet/SheetRootControl.xaml b/UniSky/Controls/Sheet/SheetRootControl.xaml new file mode 100644 index 0000000..fc4ebb4 --- /dev/null +++ b/UniSky/Controls/Sheet/SheetRootControl.xaml @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/UniSky/Controls/Sheet/SheetRootControl.xaml.cs b/UniSky/Controls/Sheet/SheetRootControl.xaml.cs new file mode 100644 index 0000000..fc8d331 --- /dev/null +++ b/UniSky/Controls/Sheet/SheetRootControl.xaml.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices.WindowsRuntime; +using UniSky.Pages; +using Windows.Foundation; +using Windows.Foundation.Collections; +using Windows.UI.Xaml; +using Windows.UI.Xaml.Controls; +using Windows.UI.Xaml.Controls.Primitives; +using Windows.UI.Xaml.Data; +using Windows.UI.Xaml.Input; +using Windows.UI.Xaml.Markup; +using Windows.UI.Xaml.Media; +using Windows.UI.Xaml.Navigation; + +// The User Control item template is documented at https://go.microsoft.com/fwlink/?LinkId=234236 + +namespace UniSky.Controls.Sheet +{ + [ContentProperty(Name = nameof(ContentElement))] + public sealed partial class SheetRootControl : UserControl + { + public SheetRootControl() + { + this.InitializeComponent(); + } + + public FrameworkElement ContentElement + { + get { return (FrameworkElement)GetValue(ContentElementProperty); } + set { SetValue(ContentElementProperty, value); } + } + + public static readonly DependencyProperty ContentElementProperty = + DependencyProperty.Register("ContentElement", typeof(FrameworkElement), typeof(SheetRootControl), new PropertyMetadata(null)); + + protected override Size ArrangeOverride(Size finalSize) + { + // TODO: this depends on the state + SheetTransform.Y = finalSize.Height; + return base.ArrangeOverride(finalSize); + } + + internal void ShowSheet(Type pageType, object parameter = null) + { + SheetTransform.Y = ActualHeight; + ShowSheetStoryboard.Begin(); + + HostControl.Navigate(pageType, parameter); + } + + internal void HideSheet() + { + HideDoubleAnimation.To = ActualHeight; + HideSheetStoryboard.Begin(); + } + } +} diff --git a/UniSky/Helpers/Composition/BirdAnimation.cs b/UniSky/Helpers/Composition/BirdAnimation.cs index 7139d29..8b5b071 100644 --- a/UniSky/Helpers/Composition/BirdAnimation.cs +++ b/UniSky/Helpers/Composition/BirdAnimation.cs @@ -12,7 +12,7 @@ namespace UniSky.Helpers.Composition; internal static class BirdAnimation { - public static void RunBirdAnimation(Frame frame) + public static void RunBirdAnimation(FrameworkElement frame) { // TODO: fallback to a shape visual if (!ApiInformation.IsMethodPresent(typeof(Compositor).FullName, "CreateGeometricClip")) diff --git a/UniSky/Package.appxmanifest b/UniSky/Package.appxmanifest index b443f75..bff0ddf 100644 --- a/UniSky/Package.appxmanifest +++ b/UniSky/Package.appxmanifest @@ -10,7 +10,7 @@ + Version="1.0.111.0" /> diff --git a/UniSky/Pages/FeedsPage.xaml b/UniSky/Pages/FeedsPage.xaml index f8c1ecb..f82b9d7 100644 --- a/UniSky/Pages/FeedsPage.xaml +++ b/UniSky/Pages/FeedsPage.xaml @@ -16,7 +16,8 @@ + ItemsSource="{x:Bind ViewModel.Feeds}" + ScrollViewer.IsVerticalScrollChainingEnabled="True"> (Ioc.Default); + var safeAreaService = Ioc.Default.GetRequiredService(); safeAreaService.SafeAreaUpdated += OnSafeAreaUpdated; - - this.ViewModel = ActivatorUtilities.CreateInstance(Ioc.Default); } private void OnSafeAreaUpdated(object sender, SafeAreaUpdatedEventArgs e) diff --git a/UniSky/Pages/LoginPage.xaml.cs b/UniSky/Pages/LoginPage.xaml.cs index 8e036f2..f0cd7c6 100644 --- a/UniSky/Pages/LoginPage.xaml.cs +++ b/UniSky/Pages/LoginPage.xaml.cs @@ -35,6 +35,11 @@ public LoginPage() this.ViewModel = ActivatorUtilities.CreateInstance(Ioc.Default); } + protected override void OnNavigatedTo(NavigationEventArgs e) + { + this.Frame.BackStack.Clear(); + } + public bool IsNotNull(object o) => o is not null; diff --git a/UniSky/Pages/ProfilePage.xaml b/UniSky/Pages/ProfilePage.xaml index 7b6cce6..b1d2a3f 100644 --- a/UniSky/Pages/ProfilePage.xaml +++ b/UniSky/Pages/ProfilePage.xaml @@ -18,7 +18,6 @@ - @@ -14,6 +15,9 @@ - + + + diff --git a/UniSky/RootPage.xaml.cs b/UniSky/RootPage.xaml.cs index 9a6a8bb..0cbcfee 100644 --- a/UniSky/RootPage.xaml.cs +++ b/UniSky/RootPage.xaml.cs @@ -65,6 +65,6 @@ userObj is string user && service.Navigate(); } - BirdAnimation.RunBirdAnimation(RootFrame); + BirdAnimation.RunBirdAnimation(SheetRoot); } } diff --git a/UniSky/UniSky.csproj b/UniSky/UniSky.csproj index 73d28b1..21b24d8 100644 --- a/UniSky/UniSky.csproj +++ b/UniSky/UniSky.csproj @@ -146,6 +146,12 @@ + + SheetHostControl.xaml + + + SheetRootControl.xaml + @@ -350,6 +356,14 @@ Designer MSBuild:Compile + + MSBuild:Compile + Designer + + + Designer + MSBuild:Compile + Designer MSBuild:Compile diff --git a/UniSky/ViewModels/FeedsViewModel.cs b/UniSky/ViewModels/FeedsViewModel.cs index 43bde7e..0458701 100644 --- a/UniSky/ViewModels/FeedsViewModel.cs +++ b/UniSky/ViewModels/FeedsViewModel.cs @@ -8,10 +8,14 @@ using FishyFlip.Models; using FishyFlip.Tools; using Microsoft.Extensions.Logging; +using Microsoft.Toolkit.Uwp.UI.Extensions; using UniSky.Controls.Compose; +using UniSky.Controls.Sheet; using UniSky.Extensions; +using UniSky.Pages; using UniSky.Services; using UniSky.ViewModels.Feeds; +using Windows.UI.Xaml; namespace UniSky.ViewModels; @@ -37,8 +41,8 @@ public FeedsViewModel( [RelayCommand] public async Task Post() { - var dialog = new ComposeDialog(); - await dialog.ShowAsync(); + Window.Current.Content.FindDescendant() + .ShowSheet(typeof(ProfilePage), (await protocolService.Protocol.GetProfileAsync(protocolService.Protocol.Session.Did)).HandleResult()); } private async Task LoadAsync() From f49cb7929c2d791f2dea1ec2103e50c4cb108ae2 Mon Sep 17 00:00:00 2001 From: Thomas May Date: Sun, 24 Nov 2024 17:01:34 +0000 Subject: [PATCH 02/16] More sheet work + initial compose sheet --- UniSky/App.xaml | 9 +- UniSky/App.xaml.cs | 1 + UniSky/Controls/Compose/ComposeDialog.xaml | 42 ---- UniSky/Controls/Compose/ComposeDialog.xaml.cs | 51 ---- UniSky/Controls/Compose/ComposeSheet.xaml | 54 +++++ UniSky/Controls/Compose/ComposeSheet.xaml.cs | 95 ++++++++ UniSky/Controls/Sheet/SheetControl.cs | 222 ++++++++++++++++++ UniSky/Controls/Sheet/SheetHostControl.xaml | 75 ------ .../Controls/Sheet/SheetHostControl.xaml.cs | 68 ------ UniSky/Controls/Sheet/SheetRootControl.xaml | 199 +++++++++++----- .../Controls/Sheet/SheetRootControl.xaml.cs | 85 +++++-- UniSky/Package.appxmanifest | 2 +- UniSky/Services/ISheetService.cs | 11 + UniSky/Services/SheetService.cs | 36 +++ UniSky/Templates/SheetControlStyles.xaml | 85 +++++++ UniSky/Templates/TextBoxStyles.xaml | 86 +++++++ UniSky/UniSky.csproj | 28 ++- UniSky/ViewModels/Compose/ComposeViewModel.cs | 9 + UniSky/ViewModels/FeedsViewModel.cs | 5 +- 19 files changed, 834 insertions(+), 329 deletions(-) delete mode 100644 UniSky/Controls/Compose/ComposeDialog.xaml delete mode 100644 UniSky/Controls/Compose/ComposeDialog.xaml.cs create mode 100644 UniSky/Controls/Compose/ComposeSheet.xaml create mode 100644 UniSky/Controls/Compose/ComposeSheet.xaml.cs create mode 100644 UniSky/Controls/Sheet/SheetControl.cs delete mode 100644 UniSky/Controls/Sheet/SheetHostControl.xaml delete mode 100644 UniSky/Controls/Sheet/SheetHostControl.xaml.cs create mode 100644 UniSky/Services/ISheetService.cs create mode 100644 UniSky/Services/SheetService.cs create mode 100644 UniSky/Templates/SheetControlStyles.xaml create mode 100644 UniSky/Templates/TextBoxStyles.xaml diff --git a/UniSky/App.xaml b/UniSky/App.xaml index f3418e3..f3fc5e8 100644 --- a/UniSky/App.xaml +++ b/UniSky/App.xaml @@ -4,7 +4,8 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local="using:UniSky" xmlns:controls="using:Microsoft.UI.Xaml.Controls" - xmlns:media="using:Microsoft.UI.Xaml.Media"> + xmlns:media="using:Microsoft.UI.Xaml.Media" + xmlns:sheets="using:UniSky.Controls.Sheet"> @@ -14,7 +15,9 @@ + + @@ -41,7 +44,9 @@ 0,0,0,0 14 - 14 + 14 + + + diff --git a/UniSky/Templates/TextBoxStyles.xaml b/UniSky/Templates/TextBoxStyles.xaml new file mode 100644 index 0000000..2465847 --- /dev/null +++ b/UniSky/Templates/TextBoxStyles.xaml @@ -0,0 +1,86 @@ + + + + diff --git a/UniSky/UniSky.csproj b/UniSky/UniSky.csproj index 21b24d8..b3b0fc3 100644 --- a/UniSky/UniSky.csproj +++ b/UniSky/UniSky.csproj @@ -140,15 +140,13 @@ App.xaml - - ComposeDialog.xaml + + ComposeSheet.xaml - - SheetHostControl.xaml - + SheetRootControl.xaml @@ -195,11 +193,13 @@ + FeedTemplates.xaml + @@ -352,14 +352,10 @@ MSBuild:Compile Designer - + Designer MSBuild:Compile - - MSBuild:Compile - Designer - Designer MSBuild:Compile @@ -400,6 +396,14 @@ Designer MSBuild:Compile + + Designer + MSBuild:Compile + + + MSBuild:Compile + Designer + @@ -459,7 +463,9 @@ UniSky.Models - + + + 14.0 diff --git a/UniSky/ViewModels/Compose/ComposeViewModel.cs b/UniSky/ViewModels/Compose/ComposeViewModel.cs index 20fad37..9f40335 100644 --- a/UniSky/ViewModels/Compose/ComposeViewModel.cs +++ b/UniSky/ViewModels/Compose/ComposeViewModel.cs @@ -4,6 +4,8 @@ using System.Text; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.DependencyInjection; +using CommunityToolkit.Mvvm.Input; using FishyFlip.Lexicon.App.Bsky.Actor; using FishyFlip.Tools; using Microsoft.Extensions.Logging; @@ -31,6 +33,13 @@ public ComposeViewModel( Task.Run(LoadAsync); } + [RelayCommand] + private async Task Hide() + { + var sheetService = Ioc.Default.GetRequiredService(); + await sheetService.TryCloseAsync(); + } + private async Task LoadAsync() { using var loading = this.GetLoadingContext(); diff --git a/UniSky/ViewModels/FeedsViewModel.cs b/UniSky/ViewModels/FeedsViewModel.cs index 0458701..7c049fa 100644 --- a/UniSky/ViewModels/FeedsViewModel.cs +++ b/UniSky/ViewModels/FeedsViewModel.cs @@ -2,6 +2,7 @@ using System.Collections.ObjectModel; using System.Linq; using System.Threading.Tasks; +using CommunityToolkit.Mvvm.DependencyInjection; using CommunityToolkit.Mvvm.Input; using FishyFlip.Lexicon.App.Bsky.Actor; using FishyFlip.Lexicon.App.Bsky.Feed; @@ -41,8 +42,8 @@ public FeedsViewModel( [RelayCommand] public async Task Post() { - Window.Current.Content.FindDescendant() - .ShowSheet(typeof(ProfilePage), (await protocolService.Protocol.GetProfileAsync(protocolService.Protocol.Session.Did)).HandleResult()); + var sheetsService = Ioc.Default.GetRequiredService(); + await sheetsService.ShowAsync(); } private async Task LoadAsync() From 60e54b2a55dd451f475f33159ccdd9d1ffd4fa0b Mon Sep 17 00:00:00 2001 From: Thomas May Date: Sun, 24 Nov 2024 21:47:05 +0000 Subject: [PATCH 03/16] More sheet work --- UniSky/App.xaml | 6 +- UniSky/Controls/Sheet/SheetControl.cs | 31 -- UniSky/Controls/Sheet/SheetRootControl.xaml | 99 ++++- .../Controls/Sheet/SheetRootControl.xaml.cs | 38 +- UniSky/Helpers/Shadows/AttachedCardShadow.cs | 175 ++++++++ UniSky/Helpers/Shadows/AttachedDropShadow.cs | 375 ++++++++++++++++++ UniSky/Helpers/Shadows/AttachedShadowBase.cs | 293 ++++++++++++++ .../Shadows/AttachedShadowElementContext.cs | 320 +++++++++++++++ UniSky/Helpers/Shadows/Effects.cs | 58 +++ UniSky/Helpers/Shadows/IAlphaMaskProvider.cs | 25 ++ UniSky/Helpers/Shadows/IAttachedShadow.cs | 50 +++ UniSky/Helpers/Shadows/TypedResourceKey.cs | 35 ++ UniSky/Helpers/StringExtensions.cs | 231 +++++++++++ UniSky/Helpers/UIElementExtensions.cs | 75 ++++ UniSky/Package.appxmanifest | 2 +- UniSky/Templates/SheetControlStyles.xaml | 1 - UniSky/UniSky.csproj | 10 + 17 files changed, 1754 insertions(+), 70 deletions(-) create mode 100644 UniSky/Helpers/Shadows/AttachedCardShadow.cs create mode 100644 UniSky/Helpers/Shadows/AttachedDropShadow.cs create mode 100644 UniSky/Helpers/Shadows/AttachedShadowBase.cs create mode 100644 UniSky/Helpers/Shadows/AttachedShadowElementContext.cs create mode 100644 UniSky/Helpers/Shadows/Effects.cs create mode 100644 UniSky/Helpers/Shadows/IAlphaMaskProvider.cs create mode 100644 UniSky/Helpers/Shadows/IAttachedShadow.cs create mode 100644 UniSky/Helpers/Shadows/TypedResourceKey.cs create mode 100644 UniSky/Helpers/StringExtensions.cs create mode 100644 UniSky/Helpers/UIElementExtensions.cs diff --git a/UniSky/App.xaml b/UniSky/App.xaml index f3fc5e8..2b36f20 100644 --- a/UniSky/App.xaml +++ b/UniSky/App.xaml @@ -17,11 +17,13 @@ - - + + + + diff --git a/UniSky/Controls/Sheet/SheetControl.cs b/UniSky/Controls/Sheet/SheetControl.cs index b335945..da3a269 100644 --- a/UniSky/Controls/Sheet/SheetControl.cs +++ b/UniSky/Controls/Sheet/SheetControl.cs @@ -186,37 +186,6 @@ internal async Task InvokeHidingAsync() private void OnLoaded(object sender, RoutedEventArgs e) { - if (!DesignMode.DesignModeEnabled) - { - var safeAreaService = Ioc.Default.GetService(); - if (safeAreaService != null) - safeAreaService.SafeAreaUpdated += OnSafeAreaUpdated; - } - } - - private void OnSafeAreaUpdated(object sender, SafeAreaUpdatedEventArgs e) - { - var sheetScrollViewer = (ScrollViewer)this.FindDescendantByName("SheetScrollViewer"); - var rootGrid = (Grid)this.FindDescendantByName("RootGrid"); - - if (rootGrid != null && sheetScrollViewer != null) - { - sheetScrollViewer.Padding = new Thickness(0, 16 + e.SafeArea.Bounds.Top, 0, 0); - rootGrid.Height = Math.Max(0, ActualHeight - (sheetScrollViewer.Padding.Top + sheetScrollViewer.Padding.Bottom)); - } - } - - protected override Size ArrangeOverride(Size finalSize) - { - this.ApplyTemplate(); - - var sheetScrollViewer = (ScrollViewer)this.FindDescendantByName("SheetScrollViewer"); - var rootGrid = (Grid)this.FindDescendantByName("RootGrid"); - - if (rootGrid != null && sheetScrollViewer != null) - rootGrid.Height = finalSize.Height - (sheetScrollViewer.Padding.Top + sheetScrollViewer.Padding.Bottom); - - return base.ArrangeOverride(finalSize); } } } diff --git a/UniSky/Controls/Sheet/SheetRootControl.xaml b/UniSky/Controls/Sheet/SheetRootControl.xaml index bb0f062..d4b39c1 100644 --- a/UniSky/Controls/Sheet/SheetRootControl.xaml +++ b/UniSky/Controls/Sheet/SheetRootControl.xaml @@ -8,6 +8,9 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:media="using:Microsoft.Toolkit.Uwp.UI.Media" xmlns:muxc="using:Microsoft.UI.Xaml.Controls" + xmlns:extensions="using:UniSky.Extensions" + xmlns:controls="using:Microsoft.Toolkit.Uwp.UI.Controls" + xmlns:ui="using:Microsoft.Toolkit.Uwp.UI" mc:Ignorable="d" d:DesignHeight="300" d:DesignWidth="400"> @@ -18,6 +21,12 @@ + + @@ -33,10 +42,8 @@ - - - + Visibility="Collapsed" + Background="#80000000"> - + + + @@ -59,11 +70,40 @@ VerticalAlignment="Top"/> - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + @@ -91,22 +131,29 @@ Storyboard.TargetProperty="Y" Duration="00:00:00.5" EasingFunction="{StaticResource ExponentialEaseEnter}"/> - + EasingFunction="{StaticResource ExponentialEaseEnter}"/>--> + @@ -115,7 +162,8 @@ - + @@ -142,22 +190,29 @@ Storyboard.TargetProperty="Y" Duration="00:00:00.5" EasingFunction="{StaticResource ExponentialEaseEnter}"/> - - - --> + + diff --git a/UniSky/Controls/Sheet/SheetRootControl.xaml.cs b/UniSky/Controls/Sheet/SheetRootControl.xaml.cs index 3c73b08..e0f80a7 100644 --- a/UniSky/Controls/Sheet/SheetRootControl.xaml.cs +++ b/UniSky/Controls/Sheet/SheetRootControl.xaml.cs @@ -2,9 +2,12 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Numerics; using System.Runtime.InteropServices.WindowsRuntime; using System.Threading.Tasks; using CommunityToolkit.Mvvm.DependencyInjection; +using Microsoft.Toolkit.Uwp.UI; +using Microsoft.Toolkit.Uwp.UI.Extensions; using UniSky.Pages; using UniSky.Services; using Windows.Foundation; @@ -55,7 +58,13 @@ public SheetRootControl() protected override Size ArrangeOverride(Size finalSize) { - TotalHeight = finalSize.Height; + if (finalSize.Width > 620) + TotalHeight = 64; + else + TotalHeight = finalSize.Height; + + SheetRoot.Height = Math.Max(0, finalSize.Height - (SheetBorder.Margin.Top + SheetBorder.Margin.Bottom)); + return base.ArrangeOverride(finalSize); } @@ -91,27 +100,30 @@ internal async Task HideSheetAsync() private void OnSafeAreaUpdated(object sender, SafeAreaUpdatedEventArgs e) { TitleBar.Height = e.SafeArea.Bounds.Top; + SheetBorder.Margin = new Thickness(0, 16 + e.SafeArea.Bounds.Top, 0, 0); + SheetRoot.Height = Math.Max(0, ActualHeight - (SheetBorder.Margin.Top + SheetBorder.Margin.Bottom)); HostControl.Margin = new Thickness(e.SafeArea.Bounds.Left, 0, e.SafeArea.Bounds.Right, e.SafeArea.Bounds.Bottom); } - private void SheetStates_CurrentStateChanged(object sender, VisualStateChangedEventArgs e) + private async void RefreshContainer_RefreshRequested(MUXC.RefreshContainer sender, MUXC.RefreshRequestedEventArgs args) { - if (e.NewState.Name == "Open") - { - - } + var deferral = args.GetDeferral(); + await HideSheetAsync(); + deferral.Complete(); + } - if (e.NewState.Name == "Closed") - { + private void HideSheetStoryboard_Completed(object sender, object e) + { + if (SheetRoot.Child is SheetControl control) + SheetRoot.Child = null; - } + Effects.SetShadow(SheetBorder, null); } - private async void RefreshContainer_RefreshRequested(MUXC.RefreshContainer sender, MUXC.RefreshRequestedEventArgs args) + private void ShowSheetStoryboard_Completed(object sender, object e) { - var deferral = args.GetDeferral(); - await HideSheetAsync(); - deferral.Complete(); + CommonShadow.CastTo = CompositionBackdropContainer; + Effects.SetShadow(SheetBorder, CommonShadow); } } } diff --git a/UniSky/Helpers/Shadows/AttachedCardShadow.cs b/UniSky/Helpers/Shadows/AttachedCardShadow.cs new file mode 100644 index 0000000..2373f7a --- /dev/null +++ b/UniSky/Helpers/Shadows/AttachedCardShadow.cs @@ -0,0 +1,175 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Numerics; +using Microsoft.Graphics.Canvas.Geometry; +using Windows.Foundation; +using Windows.UI; +using Windows.UI.Composition; +using Windows.UI.Xaml; + +namespace Microsoft.Toolkit.Uwp.UI.Media +{ + /// + /// A performant rectangular which can be attached to any . It uses Win2D to create a clipped area of the outline of the element such that transparent elements don't see the shadow below them, and the shadow can be attached without having to project to another surface. It is animatable, can be shared via a resource, and used in a . + /// + /// + /// This shadow will not work on which is directly clipping to its bounds (e.g. a using a ). An extra can instead be applied around the clipped border with the Shadow to create the desired effect. Most existing controls due to how they're templated will not encounter this behavior or require this workaround. + /// + public sealed class AttachedCardShadow : AttachedShadowBase + { + private const float MaxBlurRadius = 72; + private static readonly TypedResourceKey ClipResourceKey = "Clip"; + + private static readonly TypedResourceKey PathGeometryResourceKey = "PathGeometry"; + private static readonly TypedResourceKey RoundedRectangleGeometryResourceKey = "RoundedGeometry"; + private static readonly TypedResourceKey ShapeResourceKey = "Shape"; + private static readonly TypedResourceKey ShapeVisualResourceKey = "ShapeVisual"; + private static readonly TypedResourceKey SurfaceBrushResourceKey = "SurfaceBrush"; + private static readonly TypedResourceKey VisualSurfaceResourceKey = "VisualSurface"; + + /// + /// The for + /// + public static readonly DependencyProperty CornerRadiusProperty = + DependencyProperty.Register( + nameof(CornerRadius), + typeof(double), + typeof(AttachedCardShadow), + new PropertyMetadata(4d, OnDependencyPropertyChanged)); // Default WinUI ControlCornerRadius is 4 + + /// + /// Gets or sets the roundness of the shadow's corners. + /// + public double CornerRadius + { + get => (double)GetValue(CornerRadiusProperty); + set => SetValue(CornerRadiusProperty, value); + } + + /// + public override bool IsSupported => SupportsCompositionVisualSurface; + + /// + protected internal override bool SupportsOnSizeChangedEvent => true; + + /// + protected override void OnPropertyChanged(AttachedShadowElementContext context, DependencyProperty property, object oldValue, object newValue) + { + if (property == CornerRadiusProperty) + { + var geometry = context.GetResource(RoundedRectangleGeometryResourceKey); + if (geometry != null) + { + geometry.CornerRadius = new Vector2((float)(double)newValue); + } + + UpdateShadowClip(context); + } + else + { + base.OnPropertyChanged(context, property, oldValue, newValue); + } + } + + /// + protected override CompositionBrush GetShadowMask(AttachedShadowElementContext context) + { + if (!SupportsCompositionVisualSurface) + { + return null; + } + + // Create rounded rectangle geometry and add it to a shape + var geometry = context.GetResource(RoundedRectangleGeometryResourceKey) ?? context.AddResource( + RoundedRectangleGeometryResourceKey, + context.Compositor.CreateRoundedRectangleGeometry()); + geometry.CornerRadius = new Vector2((float)CornerRadius); + + var shape = context.GetResource(ShapeResourceKey) ?? context.AddResource(ShapeResourceKey, context.Compositor.CreateSpriteShape(geometry)); + shape.FillBrush = context.Compositor.CreateColorBrush(Colors.Black); + + // Create a ShapeVisual so that our geometry can be rendered to a visual + var shapeVisual = context.GetResource(ShapeVisualResourceKey) ?? + context.AddResource(ShapeVisualResourceKey, context.Compositor.CreateShapeVisual()); + shapeVisual.Shapes.Add(shape); + + // Create a CompositionVisualSurface, which renders our ShapeVisual to a texture + var visualSurface = context.GetResource(VisualSurfaceResourceKey) ?? + context.AddResource(VisualSurfaceResourceKey, context.Compositor.CreateVisualSurface()); + visualSurface.SourceVisual = shapeVisual; + + // Create a CompositionSurfaceBrush to render our CompositionVisualSurface to a brush. + // Now we have a rounded rectangle brush that can be used on as the mask for our shadow. + var surfaceBrush = context.GetResource(SurfaceBrushResourceKey) ?? context.AddResource( + SurfaceBrushResourceKey, + context.Compositor.CreateSurfaceBrush(visualSurface)); + + geometry.Size = visualSurface.SourceSize = shapeVisual.Size = context.Element.RenderSize.ToVector2(); + + return surfaceBrush; + } + + /// + protected override CompositionClip GetShadowClip(AttachedShadowElementContext context) + { + // The way this shadow works without the need to project on another element is because + // we're clipping the inner part of the shadow which would be cast on the element + // itself away. This method is creating an outline so that we are only showing the + // parts of the shadow that are outside the element's context. + // Note: This does cause an issue if the element does clip itself to its bounds, as then + // the shadowed area is clipped as well. + var pathGeom = context.GetResource(PathGeometryResourceKey) ?? + context.AddResource(PathGeometryResourceKey, context.Compositor.CreatePathGeometry()); + var clip = context.GetResource(ClipResourceKey) ?? context.AddResource(ClipResourceKey, context.Compositor.CreateGeometricClip(pathGeom)); + + // Create rounded rectangle geometry at a larger size that compensates for the size of the stroke, + // as we want the inside edge of the stroke to match the edges of the element. + // Additionally, the inside edge of the stroke will have a smaller radius than the radius we specified. + // Using "(StrokeThickness / 2) + Radius" as our rectangle's radius will give us an inside stroke radius that matches the radius we want. + var canvasRectangle = CanvasGeometry.CreateRoundedRectangle( + null, + -MaxBlurRadius / 2, + -MaxBlurRadius / 2, + (float)context.Element.ActualWidth + MaxBlurRadius, + (float)context.Element.ActualHeight + MaxBlurRadius, + (MaxBlurRadius / 2) + (float)CornerRadius, + (MaxBlurRadius / 2) + (float)CornerRadius); + + var canvasStroke = canvasRectangle.Stroke(MaxBlurRadius); + + pathGeom.Path = new CompositionPath(canvasStroke); + + return clip; + } + + /// + protected internal override void OnSizeChanged(AttachedShadowElementContext context, Size newSize, Size previousSize) + { + var sizeAsVec2 = newSize.ToVector2(); + + var geometry = context.GetResource(RoundedRectangleGeometryResourceKey); + if (geometry != null) + { + geometry.Size = sizeAsVec2; + } + + var visualSurface = context.GetResource(VisualSurfaceResourceKey); + if (geometry != null) + { + visualSurface.SourceSize = sizeAsVec2; + } + + var shapeVisual = context.GetResource(ShapeVisualResourceKey); + if (geometry != null) + { + shapeVisual.Size = sizeAsVec2; + } + + UpdateShadowClip(context); + + base.OnSizeChanged(context, newSize, previousSize); + } + } +} \ No newline at end of file diff --git a/UniSky/Helpers/Shadows/AttachedDropShadow.cs b/UniSky/Helpers/Shadows/AttachedDropShadow.cs new file mode 100644 index 0000000..abc4ebb --- /dev/null +++ b/UniSky/Helpers/Shadows/AttachedDropShadow.cs @@ -0,0 +1,375 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Linq; +using System.Numerics; +using Microsoft.Toolkit.Uwp.UI.Media.Geometry; +using Windows.Foundation; +using Windows.UI; +using Windows.UI.Composition; +using Windows.UI.Xaml; +using Windows.UI.Xaml.Controls; +using Windows.UI.Xaml.Hosting; +using Windows.UI.Xaml.Shapes; + +namespace Microsoft.Toolkit.Uwp.UI +{ + /// + /// A helper to add a composition based drop shadow to a . + /// + public sealed class AttachedDropShadow : AttachedShadowBase + { + private const float MaxBlurRadius = 72; + + /// + public override bool IsSupported => true; + + /// + protected internal override bool SupportsOnSizeChangedEvent => true; + + private static readonly TypedResourceKey RoundedRectangleGeometryResourceKey = "RoundedGeometry"; + private static readonly TypedResourceKey ShapeResourceKey = "Shape"; + private static readonly TypedResourceKey ShapeVisualResourceKey = "ShapeVisual"; + private static readonly TypedResourceKey SurfaceBrushResourceKey = "SurfaceBrush"; + private static readonly TypedResourceKey VisualSurfaceResourceKey = "VisualSurface"; + + /// + /// Gets or sets a value indicating whether the panel uses an alpha mask to create a more precise shadow vs. a quicker rectangle shape. + /// + /// + /// Turn this off to lose fidelity and gain performance of the panel. + /// + public bool IsMasked + { + get { return (bool)GetValue(IsMaskedProperty); } + set { SetValue(IsMaskedProperty, value); } + } + + /// + /// Identifies the dependency property. + /// + public static readonly DependencyProperty IsMaskedProperty = + DependencyProperty.Register(nameof(IsMasked), typeof(bool), typeof(AttachedDropShadow), new PropertyMetadata(true, OnDependencyPropertyChanged)); + + /// + /// Gets or sets the roundness of the shadow's corners. + /// + public double CornerRadius + { + get => (double)GetValue(CornerRadiusProperty); + set => SetValue(CornerRadiusProperty, value); + } + + /// + /// The for + /// + public static readonly DependencyProperty CornerRadiusProperty = + DependencyProperty.Register( + nameof(CornerRadius), + typeof(double), + typeof(AttachedDropShadow), + new PropertyMetadata(4d, OnDependencyPropertyChanged)); // Default WinUI ControlCornerRadius is 4 + + /// + /// Gets or sets the to be used as a backdrop to cast shadows on. + /// + public FrameworkElement CastTo + { + get { return (FrameworkElement)GetValue(CastToProperty); } + set { SetValue(CastToProperty, value); } + } + + /// + /// The for + /// + public static readonly DependencyProperty CastToProperty = + DependencyProperty.Register(nameof(CastTo), typeof(FrameworkElement), typeof(AttachedDropShadow), new PropertyMetadata(null, OnCastToPropertyChanged)); // TODO: Property Change + + private ContainerVisual _container; + + private static void OnCastToPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is AttachedDropShadow shadow) + { + if (e.OldValue is FrameworkElement element) + { + ElementCompositionPreview.SetElementChildVisual(element, null); + element.SizeChanged -= shadow.CastToElement_SizeChanged; + } + + if (e.NewValue is FrameworkElement elementNew) + { + var prevContainer = shadow._container; + + var child = ElementCompositionPreview.GetElementChildVisual(elementNew); + if (child is ContainerVisual visual) + { + shadow._container = visual; + } + else + { + var compositor = ElementCompositionPreview.GetElementVisual(shadow.CastTo).Compositor; + shadow._container = compositor.CreateContainerVisual(); + + ElementCompositionPreview.SetElementChildVisual(elementNew, shadow._container); + } + + // Need to remove all old children from previous container if it's changed + if (prevContainer != null && prevContainer != shadow._container) + { + foreach (var context in shadow.EnumerateElementContexts()) + { + if (context.IsInitialized && + prevContainer.Children.Contains(context.SpriteVisual)) + { + prevContainer.Children.Remove(context.SpriteVisual); + } + } + } + + // Make sure all child shadows are hooked into container + foreach (var context in shadow.EnumerateElementContexts()) + { + if (context.IsInitialized) + { + shadow.SetElementChildVisual(context); + } + } + + elementNew.SizeChanged += shadow.CastToElement_SizeChanged; + + // Re-trigger updates to all shadow locations for new parent + shadow.CastToElement_SizeChanged(null, null); + } + } + } + + private void CastToElement_SizeChanged(object sender, SizeChangedEventArgs e) + { + // Don't use sender or 'e' here as related to container element not + // element for shadow, grab values off context. (Also may be null from internal call.) + foreach (var context in EnumerateElementContexts()) + { + if (context.IsInitialized) + { + // TODO: Should we use ActualWidth/Height instead of RenderSize? + OnSizeChanged(context, context.Element.RenderSize, context.Element.RenderSize); + } + } + } + + /// + protected internal override void OnElementContextUninitialized(AttachedShadowElementContext context) + { + if (_container != null && _container.Children.Contains(context.SpriteVisual)) + { + _container.Children.Remove(context.SpriteVisual); + } + + context.SpriteVisual?.StopAnimation("Size"); + + context.Element.LayoutUpdated -= Element_LayoutUpdated; + + if (context.VisibilityToken != null) + { + context.Element.UnregisterPropertyChangedCallback(UIElement.VisibilityProperty, context.VisibilityToken.Value); + context.VisibilityToken = null; + } + + base.OnElementContextUninitialized(context); + } + + /// + protected override void SetElementChildVisual(AttachedShadowElementContext context) + { + if (_container != null && !_container.Children.Contains(context.SpriteVisual)) + { + _container.Children.InsertAtTop(context.SpriteVisual); + } + + // Handles size changing and other elements around it updating. + context.Element.LayoutUpdated -= Element_LayoutUpdated; + context.Element.LayoutUpdated += Element_LayoutUpdated; + + if (context.VisibilityToken != null) + { + context.Element.UnregisterPropertyChangedCallback(UIElement.VisibilityProperty, context.VisibilityToken.Value); + context.VisibilityToken = null; + } + + context.VisibilityToken = context.Element.RegisterPropertyChangedCallback(UIElement.VisibilityProperty, Element_VisibilityChanged); + } + + private void Element_LayoutUpdated(object sender, object e) + { + // Update other shadows to account for layout changes + CastToElement_SizeChanged(null, null); + } + + private void Element_VisibilityChanged(DependencyObject sender, DependencyProperty dp) + { + if (sender is FrameworkElement element) + { + var context = GetElementContext(element); + + if (element.Visibility == Visibility.Collapsed) + { + if (_container != null && _container.Children.Contains(context.SpriteVisual)) + { + _container.Children.Remove(context.SpriteVisual); + } + } + else + { + if (_container != null && !_container.Children.Contains(context.SpriteVisual)) + { + _container.Children.InsertAtTop(context.SpriteVisual); + } + } + } + + // Update other shadows to account for layout changes + CastToElement_SizeChanged(null, null); + } + + /// + protected override CompositionBrush GetShadowMask(AttachedShadowElementContext context) + { + CompositionBrush mask = null; + + if (DesignTimeHelpers.IsRunningInLegacyDesignerMode) + { + return null; + } + + if (context.Element != null) + { + if (IsMasked) + { + // We check for IAlphaMaskProvider first, to ensure that we use the custom + // alpha mask even if Content happens to extend any of the other classes + if (context.Element is IAlphaMaskProvider maskedControl) + { + if (maskedControl.WaitUntilLoaded && !context.Element.IsLoaded) + { + context.Element.Loaded += CustomMaskedElement_Loaded; + } + else + { + mask = maskedControl.GetAlphaMask(); + } + } + else if (context.Element is Image) + { + mask = ((Image)context.Element).GetAlphaMask(); + } + else if (context.Element is Shape) + { + mask = ((Shape)context.Element).GetAlphaMask(); + } + else if (context.Element is TextBlock) + { + mask = ((TextBlock)context.Element).GetAlphaMask(); + } + } + + // If we don't have a mask and have specified rounded corners, we'll generate a simple quick mask. + // This is the same code from link:AttachedCardShadow.cs:GetShadowMask + if (mask == null && SupportsCompositionVisualSurface && CornerRadius > 0) + { + // Create rounded rectangle geometry and add it to a shape + var geometry = context.GetResource(RoundedRectangleGeometryResourceKey) ?? context.AddResource( + RoundedRectangleGeometryResourceKey, + context.Compositor.CreateRoundedRectangleGeometry()); + geometry.CornerRadius = new Vector2((float)CornerRadius); + + var shape = context.GetResource(ShapeResourceKey) ?? context.AddResource(ShapeResourceKey, context.Compositor.CreateSpriteShape(geometry)); + shape.FillBrush = context.Compositor.CreateColorBrush(Colors.Black); + + // Create a ShapeVisual so that our geometry can be rendered to a visual + var shapeVisual = context.GetResource(ShapeVisualResourceKey) ?? + context.AddResource(ShapeVisualResourceKey, context.Compositor.CreateShapeVisual()); + shapeVisual.Shapes.Add(shape); + + // Create a CompositionVisualSurface, which renders our ShapeVisual to a texture + var visualSurface = context.GetResource(VisualSurfaceResourceKey) ?? + context.AddResource(VisualSurfaceResourceKey, context.Compositor.CreateVisualSurface()); + visualSurface.SourceVisual = shapeVisual; + + // Create a CompositionSurfaceBrush to render our CompositionVisualSurface to a brush. + // Now we have a rounded rectangle brush that can be used on as the mask for our shadow. + var surfaceBrush = context.GetResource(SurfaceBrushResourceKey) ?? context.AddResource( + SurfaceBrushResourceKey, + context.Compositor.CreateSurfaceBrush(visualSurface)); + + geometry.Size = visualSurface.SourceSize = shapeVisual.Size = context.Element.RenderSize.ToVector2(); + + mask = surfaceBrush; + } + } + + // Position our shadow in the correct spot to match the corresponding element. + context.SpriteVisual.Offset = context.Element.CoordinatesFrom(CastTo).ToVector3(); + + BindSizeAndScale(context.SpriteVisual, context.Element); + + return mask; + } + + private static void BindSizeAndScale(CompositionObject source, UIElement target) + { + var visual = ElementCompositionPreview.GetElementVisual(target); + var bindSizeAnimation = source.Compositor.CreateExpressionAnimation($"{nameof(visual)}.Size * {nameof(visual)}.Scale.XY"); + + bindSizeAnimation.SetReferenceParameter(nameof(visual), visual); + + // Start the animation + source.StartAnimation("Size", bindSizeAnimation); + } + + private void CustomMaskedElement_Loaded(object sender, RoutedEventArgs e) + { + var context = GetElementContext(sender as FrameworkElement); + + context.Element.Loaded -= CustomMaskedElement_Loaded; + + UpdateShadowClip(context); + UpdateShadowMask(context); + } + + /// + protected internal override void OnSizeChanged(AttachedShadowElementContext context, Size newSize, Size previousSize) + { + context.SpriteVisual.Offset = context.Element.CoordinatesFrom(CastTo).ToVector3(); + + UpdateShadowClip(context); + + base.OnSizeChanged(context, newSize, previousSize); + } + + /// + protected override void OnPropertyChanged(AttachedShadowElementContext context, DependencyProperty property, object oldValue, object newValue) + { + if (property == IsMaskedProperty) + { + UpdateShadowMask(context); + } + else if (property == CornerRadiusProperty) + { + var geometry = context.GetResource(RoundedRectangleGeometryResourceKey); + if (geometry != null) + { + geometry.CornerRadius = new Vector2((float)(double)newValue); + } + + UpdateShadowMask(context); + } + else + { + base.OnPropertyChanged(context, property, oldValue, newValue); + } + } + } +} diff --git a/UniSky/Helpers/Shadows/AttachedShadowBase.cs b/UniSky/Helpers/Shadows/AttachedShadowBase.cs new file mode 100644 index 0000000..dccd84e --- /dev/null +++ b/UniSky/Helpers/Shadows/AttachedShadowBase.cs @@ -0,0 +1,293 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using System.Numerics; +using System.Runtime.CompilerServices; +using Windows.Foundation; +using Windows.Foundation.Metadata; +using Windows.UI; +using Windows.UI.Composition; +using Windows.UI.Xaml; +using Windows.UI.Xaml.Hosting; + +namespace Microsoft.Toolkit.Uwp.UI +{ + /// + /// The base class for attached shadows. + /// + public abstract class AttachedShadowBase : DependencyObject, IAttachedShadow + { + /// + /// Gets a value indicating whether or not Composition's VisualSurface is supported. + /// + protected static readonly bool SupportsCompositionVisualSurface = ApiInformation.IsTypePresent(typeof(CompositionVisualSurface).FullName); + + /// + /// The for . + /// + public static readonly DependencyProperty BlurRadiusProperty = + DependencyProperty.Register(nameof(BlurRadius), typeof(double), typeof(AttachedShadowBase), new PropertyMetadata(12d, OnDependencyPropertyChanged)); + + /// + /// The for . + /// + public static readonly DependencyProperty ColorProperty = + DependencyProperty.Register(nameof(Color), typeof(Color), typeof(AttachedShadowBase), new PropertyMetadata(Colors.Black, OnDependencyPropertyChanged)); + + /// + /// The for . + /// + public static readonly DependencyProperty OffsetProperty = + DependencyProperty.Register( + nameof(Offset), + typeof(string), // Needs to be string as we can't convert in XAML natively from Vector3, see https://github.com/microsoft/microsoft-ui-xaml/issues/3896 + typeof(AttachedShadowBase), + new PropertyMetadata(string.Empty, OnDependencyPropertyChanged)); + + /// + /// The for + /// + public static readonly DependencyProperty OpacityProperty = + DependencyProperty.Register(nameof(Opacity), typeof(double), typeof(AttachedShadowBase), new PropertyMetadata(1d, OnDependencyPropertyChanged)); + + /// + /// Gets a value indicating whether or not this implementation is supported on the current platform. + /// + public abstract bool IsSupported { get; } + + /// + /// Gets or sets the collection of for each element this is connected to. + /// + private ConditionalWeakTable ShadowElementContextTable { get; set; } + + /// + public double BlurRadius + { + get => (double)GetValue(BlurRadiusProperty); + set => SetValue(BlurRadiusProperty, value); + } + + /// + public double Opacity + { + get => (double)GetValue(OpacityProperty); + set => SetValue(OpacityProperty, value); + } + + /// + public string Offset + { + get => (string)GetValue(OffsetProperty); + set => SetValue(OffsetProperty, value); + } + + /// + public Color Color + { + get => (Color)GetValue(ColorProperty); + set => SetValue(ColorProperty, value); + } + + /// + /// Gets a value indicating whether or not OnSizeChanged should be called when is fired. + /// + protected internal abstract bool SupportsOnSizeChangedEvent { get; } + + /// + /// Use this method as the for DependencyProperties in derived classes. + /// + protected static void OnDependencyPropertyChanged(object sender, DependencyPropertyChangedEventArgs args) + { + (sender as AttachedShadowBase)?.CallPropertyChangedForEachElement(args.Property, args.OldValue, args.NewValue); + } + + internal void ConnectElement(FrameworkElement element) + { + if (!IsSupported) + { + return; + } + + ShadowElementContextTable = ShadowElementContextTable ?? new ConditionalWeakTable(); + if (ShadowElementContextTable.TryGetValue(element, out var context)) + { + return; + } + + context = new AttachedShadowElementContext(); + context.ConnectToElement(this, element); + ShadowElementContextTable.Add(element, context); + } + + internal void DisconnectElement(FrameworkElement element) + { + if (ShadowElementContextTable == null) + { + return; + } + + if (ShadowElementContextTable.TryGetValue(element, out var context)) + { + context.DisconnectFromElement(); + ShadowElementContextTable.Remove(element); + } + } + + /// + /// Override to handle when the for an element is being initialized. + /// + /// The that is being initialized. + protected internal virtual void OnElementContextInitialized(AttachedShadowElementContext context) + { + OnPropertyChanged(context, OpacityProperty, Opacity, Opacity); + OnPropertyChanged(context, BlurRadiusProperty, BlurRadius, BlurRadius); + OnPropertyChanged(context, ColorProperty, Color, Color); + OnPropertyChanged(context, OffsetProperty, Offset, Offset); + UpdateShadowClip(context); + UpdateShadowMask(context); + SetElementChildVisual(context); + } + + /// + /// Override to handle when the for an element is being uninitialized. + /// + /// The that is being uninitialized. + protected internal virtual void OnElementContextUninitialized(AttachedShadowElementContext context) + { + context.ClearAndDisposeResources(); + ElementCompositionPreview.SetElementChildVisual(context.Element, null); + } + + /// + public AttachedShadowElementContext GetElementContext(FrameworkElement element) + { + if (ShadowElementContextTable != null && ShadowElementContextTable.TryGetValue(element, out var context)) + { + return context; + } + + return null; + } + + /// + public IEnumerable EnumerateElementContexts() + { + if (ShadowElementContextTable != null) + { + foreach (var kvp in ShadowElementContextTable) + { + yield return kvp.Value; + } + } + } + + /// + /// Sets as a child visual on + /// + /// The this operaiton will be performed on. + protected virtual void SetElementChildVisual(AttachedShadowElementContext context) + { + ElementCompositionPreview.SetElementChildVisual(context.Element, context.SpriteVisual); + } + + private void CallPropertyChangedForEachElement(DependencyProperty property, object oldValue, object newValue) + { + if (ShadowElementContextTable == null) + { + return; + } + + foreach (var context in ShadowElementContextTable) + { + if (context.Value.IsInitialized) + { + OnPropertyChanged(context.Value, property, oldValue, newValue); + } + } + } + + /// + /// Get a in the shape of the element that is casting the shadow. + /// + /// A representing the shape of an element. + protected virtual CompositionBrush GetShadowMask(AttachedShadowElementContext context) + { + return null; + } + + /// + /// Get the for the shadow's + /// + /// A for the extent of the shadowed area. + protected virtual CompositionClip GetShadowClip(AttachedShadowElementContext context) + { + return null; + } + + /// + /// Update the mask that gives the shadow its shape. + /// + protected void UpdateShadowMask(AttachedShadowElementContext context) + { + if (!context.IsInitialized) + { + return; + } + + context.Shadow.Mask = GetShadowMask(context); + } + + /// + /// Update the clipping on the shadow's . + /// + protected void UpdateShadowClip(AttachedShadowElementContext context) + { + if (!context.IsInitialized) + { + return; + } + + context.SpriteVisual.Clip = GetShadowClip(context); + } + + /// + /// This method is called when a DependencyProperty is changed. + /// + protected virtual void OnPropertyChanged(AttachedShadowElementContext context, DependencyProperty property, object oldValue, object newValue) + { + if (!context.IsInitialized) + { + return; + } + + if (property == BlurRadiusProperty) + { + context.Shadow.BlurRadius = (float)(double)newValue; + } + else if (property == OpacityProperty) + { + context.Shadow.Opacity = (float)(double)newValue; + } + else if (property == ColorProperty) + { + context.Shadow.Color = (Color)newValue; + } + else if (property == OffsetProperty) + { + context.Shadow.Offset = (Vector3)(newValue as string)?.ToVector3(); + } + } + + /// + /// This method is called when the element size changes, and = true. + /// + /// The for the firing its SizeChanged event + /// The new size of the + /// The previous size of the + protected internal virtual void OnSizeChanged(AttachedShadowElementContext context, Size newSize, Size previousSize) + { + } + } +} \ No newline at end of file diff --git a/UniSky/Helpers/Shadows/AttachedShadowElementContext.cs b/UniSky/Helpers/Shadows/AttachedShadowElementContext.cs new file mode 100644 index 0000000..3f60050 --- /dev/null +++ b/UniSky/Helpers/Shadows/AttachedShadowElementContext.cs @@ -0,0 +1,320 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Numerics; +using Windows.UI.Composition; +using Windows.UI.Xaml; +using Windows.UI.Xaml.Hosting; + +namespace Microsoft.Toolkit.Uwp.UI +{ + /// + /// Class which maintains the context of a for a particular linked to the definition of that shadow provided by the implementation being used. + /// + public sealed class AttachedShadowElementContext + { + private bool _isConnected; + + private Dictionary _resources; + + internal long? VisibilityToken { get; set; } + + /// + /// Gets a value indicating whether or not this has been initialized. + /// + public bool IsInitialized { get; private set; } + + /// + /// Gets the that contains this . + /// + public AttachedShadowBase Parent { get; private set; } + + /// + /// Gets the this instance is attached to + /// + public FrameworkElement Element { get; private set; } + + /// + /// Gets the for the this instance is attached to. + /// + public Visual ElementVisual { get; private set; } + + /// + /// Gets the for this instance. + /// + public Compositor Compositor { get; private set; } + + /// + /// Gets the that contains the shadow for this instance + /// + public SpriteVisual SpriteVisual { get; private set; } + + /// + /// Gets the that is rendered on this instance's + /// + public DropShadow Shadow { get; private set; } + + /// + /// Connects a to its parent definition. + /// + /// The that is using this context. + /// The that a shadow is being attached to. + internal void ConnectToElement(AttachedShadowBase parent, FrameworkElement element) + { + if (_isConnected) + { + throw new InvalidOperationException("This AttachedShadowElementContext has already been connected to an element"); + } + + _isConnected = true; + Parent = parent ?? throw new ArgumentNullException(nameof(parent)); + Element = element ?? throw new ArgumentNullException(nameof(element)); + Element.Loaded += OnElementLoaded; + Element.Unloaded += OnElementUnloaded; + Initialize(); + } + + internal void DisconnectFromElement() + { + if (!_isConnected) + { + return; + } + + Uninitialize(); + + Element.Loaded -= OnElementLoaded; + Element.Unloaded -= OnElementUnloaded; + Element = null; + + Parent = null; + + _isConnected = false; + } + + /// + /// Force early creation of this instance's resources, otherwise they will be created automatically when is loaded. + /// + public void CreateResources() => Initialize(true); + + private void Initialize(bool forceIfNotLoaded = false) + { + if (IsInitialized || !_isConnected) + { + return; + } + + IsInitialized = true; + + ElementVisual = ElementCompositionPreview.GetElementVisual(Element); + Compositor = ElementVisual.Compositor; + + Shadow = Compositor.CreateDropShadow(); + + SpriteVisual = Compositor.CreateSpriteVisual(); + SpriteVisual.RelativeSizeAdjustment = Vector2.One; + SpriteVisual.Shadow = Shadow; + + if (Parent.SupportsOnSizeChangedEvent) + { + Element.SizeChanged += OnElementSizeChanged; + } + + Parent?.OnElementContextInitialized(this); + } + + private void Uninitialize() + { + if (!IsInitialized) + { + return; + } + + IsInitialized = false; + + Parent.OnElementContextUninitialized(this); + + SpriteVisual.Shadow = null; + SpriteVisual.Dispose(); + + Shadow.Dispose(); + + ElementCompositionPreview.SetElementChildVisual(Element, null); + + Element.SizeChanged -= OnElementSizeChanged; + + SpriteVisual = null; + Shadow = null; + ElementVisual = null; + } + + private void OnElementUnloaded(object sender, RoutedEventArgs e) + { + Uninitialize(); + } + + private void OnElementLoaded(object sender, RoutedEventArgs e) + { + Initialize(); + } + + private void OnElementSizeChanged(object sender, SizeChangedEventArgs e) + { + Parent?.OnSizeChanged(this, e.NewSize, e.PreviousSize); + } + + /// + /// Adds a resource to this instance's resource dictionary with the specified key + /// + /// The type of the resource being added. + /// Key to use to lookup the resource later. + /// Object to store within the resource dictionary. + /// The added resource + public T AddResource(string key, T resource) + { + _resources = _resources ?? new Dictionary(); + if (_resources.ContainsKey(key)) + { + _resources[key] = resource; + } + else + { + _resources.Add(key, resource); + } + + return resource; + } + + /// + /// Retrieves a resource with the specified key and type if it exists + /// + /// The type of the resource being retrieved. + /// Key to use to lookup the resource. + /// Object to retrieved from the resource dictionary or default value. + /// True if the resource exists, false otherwise + public bool TryGetResource(string key, out T resource) + { + if (_resources != null && _resources.TryGetValue(key, out var objResource) && objResource is T tResource) + { + resource = tResource; + return true; + } + + resource = default; + return false; + } + + /// + /// Retries a resource with the specified key and type + /// + /// The type of the resource being retrieved. + /// Key to use to lookup the resource. + /// The resource if available, otherwise default value. + public T GetResource(string key) + { + if (TryGetResource(key, out T resource)) + { + return resource; + } + + return default; + } + + /// + /// Removes an existing resource with the specified key and type + /// + /// The type of the resource being removed. + /// Key to use to lookup the resource. + /// The resource that was removed, if any + public T RemoveResource(string key) + { + if (_resources.TryGetValue(key, out var objResource)) + { + _resources.Remove(key); + if (objResource is T resource) + { + return resource; + } + } + + return default; + } + + /// + /// Removes an existing resource with the specified key and type, and disposes it + /// + /// The type of the resource being removed. + /// Key to use to lookup the resource. + /// The resource that was removed, if any + public T RemoveAndDisposeResource(string key) + where T : IDisposable + { + if (_resources.TryGetValue(key, out var objResource)) + { + _resources.Remove(key); + if (objResource is T resource) + { + resource.Dispose(); + return resource; + } + } + + return default; + } + + /// + /// Adds a resource to this instance's collection with the specified key + /// + /// The type of the resource being added. + /// The resource that was added + internal T AddResource(TypedResourceKey key, T resource) => AddResource(key.Key, resource); + + /// + /// Retrieves a resource with the specified key and type if it exists + /// + /// The type of the resource being retrieved. + /// True if the resource exists, false otherwise + internal bool TryGetResource(TypedResourceKey key, out T resource) => TryGetResource(key.Key, out resource); + + /// + /// Retries a resource with the specified key and type + /// + /// The type of the resource being retrieved. + /// The resource if it exists or a default value. + internal T GetResource(TypedResourceKey key) => GetResource(key.Key); + + /// + /// Removes an existing resource with the specified key and type + /// + /// The type of the resource being removed. + /// The resource that was removed, if any + internal T RemoveResource(TypedResourceKey key) => RemoveResource(key.Key); + + /// + /// Removes an existing resource with the specified key and type, and disposes it + /// + /// The type of the resource being removed. + /// The resource that was removed, if any + internal T RemoveAndDisposeResource(TypedResourceKey key) + where T : IDisposable => RemoveAndDisposeResource(key.Key); + + /// + /// Disposes of any resources that implement and then clears all resources + /// + public void ClearAndDisposeResources() + { + if (_resources != null) + { + foreach (var kvp in _resources) + { + (kvp.Value as IDisposable)?.Dispose(); + } + + _resources.Clear(); + } + } + } +} \ No newline at end of file diff --git a/UniSky/Helpers/Shadows/Effects.cs b/UniSky/Helpers/Shadows/Effects.cs new file mode 100644 index 0000000..5cb2235 --- /dev/null +++ b/UniSky/Helpers/Shadows/Effects.cs @@ -0,0 +1,58 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Windows.UI.Xaml; + +namespace Microsoft.Toolkit.Uwp.UI +{ + /// + /// Helper class for attaching shadows to s. + /// + public static class Effects + { + /// + /// Gets the shadow attached to a by getting the value of the property. + /// + /// The the is attached to. + /// The that is attached to the FrameworkElement. + public static AttachedShadowBase GetShadow(FrameworkElement obj) + { + return (AttachedShadowBase)obj.GetValue(ShadowProperty); + } + + /// + /// Attaches a shadow to an element by setting the property. + /// + /// The to attach the shadow to. + /// The that will be attached to the element + public static void SetShadow(FrameworkElement obj, AttachedShadowBase value) + { + obj.SetValue(ShadowProperty, value); + } + + /// + /// Attached for setting an to a . + /// + public static readonly DependencyProperty ShadowProperty = + DependencyProperty.RegisterAttached("Shadow", typeof(AttachedShadowBase), typeof(Effects), new PropertyMetadata(null, OnShadowChanged)); + + private static void OnShadowChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (!(d is FrameworkElement element)) + { + return; + } + + if (e.OldValue is AttachedShadowBase oldShadow) + { + oldShadow.DisconnectElement(element); + } + + if (e.NewValue is AttachedShadowBase newShadow) + { + newShadow.ConnectElement(element); + } + } + } +} diff --git a/UniSky/Helpers/Shadows/IAlphaMaskProvider.cs b/UniSky/Helpers/Shadows/IAlphaMaskProvider.cs new file mode 100644 index 0000000..011560e --- /dev/null +++ b/UniSky/Helpers/Shadows/IAlphaMaskProvider.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Windows.UI.Composition; + +namespace Microsoft.Toolkit.Uwp.UI +{ + /// + /// Any user control can implement this interface to provide a custom alpha mask to it's parent DropShadowPanel + /// + public interface IAlphaMaskProvider + { + /// + /// Gets a value indicating whether the AlphaMask needs to be retrieved after the element has loaded. + /// + bool WaitUntilLoaded { get; } + + /// + /// This method should return the appropiate alpha mask to be used in the shadow of this control + /// + /// The alpha mask as a composition brush + CompositionBrush GetAlphaMask(); + } +} \ No newline at end of file diff --git a/UniSky/Helpers/Shadows/IAttachedShadow.cs b/UniSky/Helpers/Shadows/IAttachedShadow.cs new file mode 100644 index 0000000..74eee79 --- /dev/null +++ b/UniSky/Helpers/Shadows/IAttachedShadow.cs @@ -0,0 +1,50 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using System.Numerics; +using Windows.Foundation; +using Windows.UI; +using Windows.UI.Xaml; + +namespace Microsoft.Toolkit.Uwp.UI +{ + /// + /// Interface representing the common properties found within an attached shadow, for implementation. + /// + public interface IAttachedShadow + { + /// + /// Gets or sets the blur radius of the shadow. + /// + double BlurRadius { get; set; } + + /// + /// Gets or sets the opacity of the shadow. + /// + double Opacity { get; set; } + + /// + /// Gets or sets the offset of the shadow as a string representation of a . + /// + string Offset { get; set; } + + /// + /// Gets or sets the color of the shadow. + /// + Color Color { get; set; } + + /// + /// Get the associated for the specified . + /// + /// The for the element. + AttachedShadowElementContext GetElementContext(FrameworkElement element); + + /// + /// Gets an enumeration over the current list of of elements using this shared shadow definition. + /// + /// Enumeration of objects. + IEnumerable EnumerateElementContexts(); + } +} diff --git a/UniSky/Helpers/Shadows/TypedResourceKey.cs b/UniSky/Helpers/Shadows/TypedResourceKey.cs new file mode 100644 index 0000000..43c7393 --- /dev/null +++ b/UniSky/Helpers/Shadows/TypedResourceKey.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; + +namespace Microsoft.Toolkit.Uwp.UI +{ + /// + /// A generic class that can be used to retrieve keyed resources of the specified type. + /// + /// The of resource the will retrieve. + internal sealed class TypedResourceKey + { + /// + /// Initializes a new instance of the class with the specified key. + /// + /// The resource's key + public TypedResourceKey(string key) => Key = key; + + /// + /// Gets the key of the resource to be retrieved. + /// + public string Key { get; } + + /// + /// Implicit operator for transforming a string into a key. + /// + /// The key string. + public static implicit operator TypedResourceKey(string key) + { + return new TypedResourceKey(key); + } + } +} diff --git a/UniSky/Helpers/StringExtensions.cs b/UniSky/Helpers/StringExtensions.cs new file mode 100644 index 0000000..68c5a6c --- /dev/null +++ b/UniSky/Helpers/StringExtensions.cs @@ -0,0 +1,231 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Diagnostics.Contracts; +using System.Globalization; +using System.Numerics; + +namespace Microsoft.Toolkit.Uwp.UI +{ + /// + /// Extension methods for the type. + /// + public static class StringExtensions + { + /// + /// Converts a value to a value. + /// This method always assumes the invariant culture for parsing values (',' separates numbers, '.' is the decimal separator). + /// The input text can either represents a single number (mapped to , or multiple components. + /// Additionally, the format "<float, float>" is also allowed (though less efficient to parse). + /// + /// A with the values to parse. + /// The parsed value. + /// Thrown when doesn't represent a valid value. + [Pure] + public static Vector2 ToVector2(this string text) + { + if (text.Length == 0) + { + return Vector2.Zero; + } + else + { + // The format or is supported + text = Unbracket(text); + + // Skip allocations when only a component is used + if (text.IndexOf(',') == -1) + { + if (float.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out float x)) + { + return new(x); + } + } + else + { + string[] values = text.Split(','); + + if (values.Length == 2) + { + if (float.TryParse(values[0], NumberStyles.Float, CultureInfo.InvariantCulture, out float x) && + float.TryParse(values[1], NumberStyles.Float, CultureInfo.InvariantCulture, out float y)) + { + return new(x, y); + } + } + } + } + + return Throw(text); + + static Vector2 Throw(string text) => throw new FormatException($"Cannot convert \"{text}\" to {nameof(Vector2)}. Use the format \"float, float\""); + } + + /// + /// Converts a value to a value. + /// This method always assumes the invariant culture for parsing values (',' separates numbers, '.' is the decimal separator). + /// The input text can either represents a single number (mapped to , or multiple components. + /// Additionally, the format "<float, float, float>" is also allowed (though less efficient to parse). + /// + /// A with the values to parse. + /// The parsed value. + /// Thrown when doesn't represent a valid value. + [Pure] + public static Vector3 ToVector3(this string text) + { + if (text.Length == 0) + { + return Vector3.Zero; + } + else + { + text = Unbracket(text); + + if (text.IndexOf(',') == -1) + { + if (float.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out float x)) + { + return new(x); + } + } + else + { + string[] values = text.Split(','); + + if (values.Length == 3) + { + if (float.TryParse(values[0], NumberStyles.Float, CultureInfo.InvariantCulture, out float x) && + float.TryParse(values[1], NumberStyles.Float, CultureInfo.InvariantCulture, out float y) && + float.TryParse(values[2], NumberStyles.Float, CultureInfo.InvariantCulture, out float z)) + { + return new(x, y, z); + } + } + else if (values.Length == 2) + { + return new(text.ToVector2(), 0); + } + } + } + + return Throw(text); + + static Vector3 Throw(string text) => throw new FormatException($"Cannot convert \"{text}\" to {nameof(Vector3)}. Use the format \"float, float, float\""); + } + + /// + /// Converts a value to a value. + /// This method always assumes the invariant culture for parsing values (',' separates numbers, '.' is the decimal separator). + /// The input text can either represents a single number (mapped to , or multiple components. + /// Additionally, the format "<float, float, float, float>" is also allowed (though less efficient to parse). + /// + /// A with the values to parse. + /// The parsed value. + /// Thrown when doesn't represent a valid value. + [Pure] + public static Vector4 ToVector4(this string text) + { + if (text.Length == 0) + { + return Vector4.Zero; + } + else + { + text = Unbracket(text); + + if (text.IndexOf(',') == -1) + { + if (float.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out float x)) + { + return new(x); + } + } + else + { + string[] values = text.Split(','); + + if (values.Length == 4) + { + if (float.TryParse(values[0], NumberStyles.Float, CultureInfo.InvariantCulture, out float x) && + float.TryParse(values[1], NumberStyles.Float, CultureInfo.InvariantCulture, out float y) && + float.TryParse(values[2], NumberStyles.Float, CultureInfo.InvariantCulture, out float z) && + float.TryParse(values[3], NumberStyles.Float, CultureInfo.InvariantCulture, out float w)) + { + return new(x, y, z, w); + } + } + else if (values.Length == 3) + { + return new(text.ToVector3(), 0); + } + else if (values.Length == 2) + { + return new(text.ToVector2(), 0, 0); + } + } + } + + return Throw(text); + + static Vector4 Throw(string text) => throw new FormatException($"Cannot convert \"{text}\" to {nameof(Vector4)}. Use the format \"float, float, float, float\""); + } + + /// + /// Converts a value to a value. + /// This method always assumes the invariant culture for parsing values (',' separates numbers, '.' is the decimal separator). + /// Additionally, the format "<float, float, float, float>" is also allowed (though less efficient to parse). + /// + /// A with the values to parse. + /// The parsed value. + /// Thrown when doesn't represent a valid value. + [Pure] + public static Quaternion ToQuaternion(this string text) + { + if (text.Length == 0) + { + return new(); + } + else + { + text = Unbracket(text); + + string[] values = text.Split(','); + + if (values.Length == 4) + { + if (float.TryParse(values[0], NumberStyles.Float, CultureInfo.InvariantCulture, out float x) && + float.TryParse(values[1], NumberStyles.Float, CultureInfo.InvariantCulture, out float y) && + float.TryParse(values[2], NumberStyles.Float, CultureInfo.InvariantCulture, out float z) && + float.TryParse(values[3], NumberStyles.Float, CultureInfo.InvariantCulture, out float w)) + { + return new(x, y, z, w); + } + } + } + + return Throw(text); + + static Quaternion Throw(string text) => throw new FormatException($"Cannot convert \"{text}\" to {nameof(Quaternion)}. Use the format \"float, float, float, float\""); + } + + /// + /// Converts an angle bracketed value to its unbracketed form (e.g. "<float, float>" to "float, float"). + /// If the value is already unbracketed, this method will return the value unchanged. + /// + /// A bracketed value. + /// The unbracketed value. + private static string Unbracket(string text) + { + if (text.Length >= 2 && + text[0] == '<' && + text[text.Length - 1] == '>') + { + text = text.Substring(1, text.Length - 2); + } + + return text; + } + } +} \ No newline at end of file diff --git a/UniSky/Helpers/UIElementExtensions.cs b/UniSky/Helpers/UIElementExtensions.cs new file mode 100644 index 0000000..d6cc353 --- /dev/null +++ b/UniSky/Helpers/UIElementExtensions.cs @@ -0,0 +1,75 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Windows.UI.Xaml; +using Windows.UI.Xaml.Hosting; +using Point = Windows.Foundation.Point; + +namespace Microsoft.Toolkit.Uwp.UI +{ + /// + /// Provides attached dependency properties for the + /// + public static class UIElementExtensions + { + /// + /// Attached that indicates whether or not the contents of the target should always be clipped to their parent's bounds. + /// + public static readonly DependencyProperty ClipToBoundsProperty = DependencyProperty.RegisterAttached( + "ClipToBounds", + typeof(bool), + typeof(UIElementExtensions), + new PropertyMetadata(null, OnClipToBoundsPropertyChanged)); + + /// + /// Gets the value of + /// + /// The to read the property value from + /// The associated with the . + public static bool GetClipToBounds(UIElement element) => (bool)element.GetValue(ClipToBoundsProperty); + + /// + /// Sets the value of + /// + /// The to set the property to + /// The new value of the attached property + public static void SetClipToBounds(UIElement element, bool value) => element.SetValue(ClipToBoundsProperty, value); + + private static void OnClipToBoundsPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is UIElement element) + { + var clipToBounds = (bool)e.NewValue; + var visual = ElementCompositionPreview.GetElementVisual(element); + visual.Clip = clipToBounds ? visual.Compositor.CreateInsetClip() : null; + } + } + + /// + /// Provides the distance in a from the passed in element to the element being called on. + /// For instance, calling child.CoordinatesFrom(container) will return the position of the child within the container. + /// Helper for . + /// + /// Element to measure distance. + /// Starting parent element to provide coordinates from. + /// containing difference in position of elements. + public static Point CoordinatesFrom(this UIElement target, UIElement parent) + { + return target.TransformToVisual(parent).TransformPoint(default(Point)); + } + + /// + /// Provides the distance in a to the passed in element from the element being called on. + /// For instance, calling container.CoordinatesTo(child) will return the position of the child within the container. + /// Helper for . + /// + /// Starting parent element to provide coordinates from. + /// Element to measure distance to. + /// containing difference in position of elements. + public static Point CoordinatesTo(this UIElement parent, UIElement target) + { + return target.TransformToVisual(parent).TransformPoint(default(Point)); + } + } +} \ No newline at end of file diff --git a/UniSky/Package.appxmanifest b/UniSky/Package.appxmanifest index df776c0..c4b8ca9 100644 --- a/UniSky/Package.appxmanifest +++ b/UniSky/Package.appxmanifest @@ -10,7 +10,7 @@ + Version="1.0.115.0" /> diff --git a/UniSky/Templates/SheetControlStyles.xaml b/UniSky/Templates/SheetControlStyles.xaml index 46f8596..a3bb6a1 100644 --- a/UniSky/Templates/SheetControlStyles.xaml +++ b/UniSky/Templates/SheetControlStyles.xaml @@ -11,7 +11,6 @@ + + + + + + + + + + ProfilePage.xaml From 74bb8ab23513237eececb4f7e8a37240cb4cb38a Mon Sep 17 00:00:00 2001 From: Thomas May Date: Sun, 24 Nov 2024 21:47:28 +0000 Subject: [PATCH 04/16] Make login more sticky --- UniSky/ViewModels/HomeViewModel.cs | 22 +++++++++++++++++++--- UniSky/ViewModels/LoginViewModel.cs | 9 +++++++-- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/UniSky/ViewModels/HomeViewModel.cs b/UniSky/ViewModels/HomeViewModel.cs index 5a55227..0d61124 100644 --- a/UniSky/ViewModels/HomeViewModel.cs +++ b/UniSky/ViewModels/HomeViewModel.cs @@ -6,6 +6,7 @@ using FishyFlip.Events; using FishyFlip.Lexicon.App.Bsky.Actor; using FishyFlip.Lexicon.App.Bsky.Notification; +using FishyFlip.Lexicon.Com.Atproto.Server; using FishyFlip.Models; using FishyFlip.Tools; using Microsoft.Extensions.Logging; @@ -132,10 +133,25 @@ private async Task LoadAsync() try { - var session = await protocol.AuthenticateWithPasswordSessionAsync(sessionModel.Session); - var refreshSession = await protocol.RefreshAuthSessionAsync(); + // to ensure the session gets refreshed properly: + // - initially authenticate the client with the refresh token + // - refresh the sesssion + // - reauthenticate with the new session - await protocol.AuthenticateWithPasswordSessionAsync(refreshSession); + var sessionRefresh = sessionModel.Session.Session; + var authSessionRefresh = new AuthSession( + new Session(sessionRefresh.Did, sessionRefresh.DidDoc, sessionRefresh.Handle, null, sessionRefresh.RefreshJwt, sessionRefresh.RefreshJwt)); + + var session = await protocol.AuthenticateWithPasswordSessionAsync(authSessionRefresh); + var refreshSession = (await protocol.RefreshSessionAsync()) + .HandleResult(); + + var session2 = await protocol.AuthenticateWithPasswordSessionAsync( + new AuthSession( + new Session(refreshSession.Did, refreshSession.DidDoc, refreshSession.Handle, null, refreshSession.AccessJwt, refreshSession.RefreshJwt))); + + if (session2 == null) + throw new InvalidOperationException("Authentication failed!"); protocolService.SetProtocol(protocol); } diff --git a/UniSky/ViewModels/LoginViewModel.cs b/UniSky/ViewModels/LoginViewModel.cs index 67c7b74..cd4f29f 100644 --- a/UniSky/ViewModels/LoginViewModel.cs +++ b/UniSky/ViewModels/LoginViewModel.cs @@ -5,7 +5,9 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using FishyFlip; +using FishyFlip.Lexicon.Com.Atproto.Server; using FishyFlip.Models; +using FishyFlip.Tools; using UniSky.Extensions; using UniSky.Helpers; using UniSky.Models; @@ -58,9 +60,12 @@ private async Task Login() .WithInstanceUrl(new Uri(Host)); using var protocol = builder.Build(); - var session = await protocol.AuthenticateWithPasswordAsync(Username, Password, CancellationToken.None) - .ConfigureAwait(false); + var createSession = (await protocol.CreateSessionAsync(Username, Password, cancellationToken: CancellationToken.None) + .ConfigureAwait(false)) + .HandleResult(); + + var session = new Session(createSession.Did, createSession.DidDoc, createSession.Handle, createSession.Email, createSession.AccessJwt, createSession.RefreshJwt); var loginModel = this.loginService.SaveLogin(normalisedHost, Username, Password); var sessionModel = new SessionModel(true, normalisedHost, session); From 73305b46a9b1242d8cb5bf953a4ea8f8e4598146 Mon Sep 17 00:00:00 2001 From: Thomas May Date: Sun, 24 Nov 2024 21:56:27 +0000 Subject: [PATCH 05/16] Make sure we're saving logins correctly --- UniSky/ViewModels/HomeViewModel.cs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/UniSky/ViewModels/HomeViewModel.cs b/UniSky/ViewModels/HomeViewModel.cs index 0d61124..7abceea 100644 --- a/UniSky/ViewModels/HomeViewModel.cs +++ b/UniSky/ViewModels/HomeViewModel.cs @@ -1,6 +1,7 @@ using System; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.DependencyInjection; using CommunityToolkit.Mvvm.Input; using FishyFlip; using FishyFlip.Events; @@ -142,17 +143,21 @@ private async Task LoadAsync() var authSessionRefresh = new AuthSession( new Session(sessionRefresh.Did, sessionRefresh.DidDoc, sessionRefresh.Handle, null, sessionRefresh.RefreshJwt, sessionRefresh.RefreshJwt)); - var session = await protocol.AuthenticateWithPasswordSessionAsync(authSessionRefresh); - var refreshSession = (await protocol.RefreshSessionAsync()) + await protocol.AuthenticateWithPasswordSessionAsync(authSessionRefresh); + var refreshSession = (await protocol.RefreshSessionAsync().ConfigureAwait(false)) .HandleResult(); - var session2 = await protocol.AuthenticateWithPasswordSessionAsync( - new AuthSession( - new Session(refreshSession.Did, refreshSession.DidDoc, refreshSession.Handle, null, refreshSession.AccessJwt, refreshSession.RefreshJwt))); + var authSession2 = new AuthSession( + new Session(refreshSession.Did, refreshSession.DidDoc, refreshSession.Handle, null, refreshSession.AccessJwt, refreshSession.RefreshJwt)); + var session2 = await protocol.AuthenticateWithPasswordSessionAsync(authSession2).ConfigureAwait(false); if (session2 == null) throw new InvalidOperationException("Authentication failed!"); + var sessionModel2 = new SessionModel(true, sessionModel.Service, authSession2.Session, authSession2); + var sessionService = Ioc.Default.GetRequiredService(); + sessionService.SaveSession(sessionModel2); + protocolService.SetProtocol(protocol); } catch (Exception ex) From d67694fa4f29c7282ed50e6e0e80ec80a87317ba Mon Sep 17 00:00:00 2001 From: Thomas May Date: Sun, 24 Nov 2024 23:36:06 +0000 Subject: [PATCH 06/16] Add F5 to refresh --- UniSky/Pages/FeedsPage.xaml | 12 ++++++++++-- UniSky/Pages/FeedsPage.xaml.cs | 8 ++++++++ UniSky/ViewModels/Feeds/FeedItemCollection.cs | 5 +++-- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/UniSky/Pages/FeedsPage.xaml b/UniSky/Pages/FeedsPage.xaml index f82b9d7..7180bc4 100644 --- a/UniSky/Pages/FeedsPage.xaml +++ b/UniSky/Pages/FeedsPage.xaml @@ -5,14 +5,22 @@ xmlns:local="using:UniSky.Pages" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:w1709="http://schemas.microsoft.com/winfx/2006/xaml/presentation?IsApiContractPresent(Windows.Foundation.UniversalApiContract, 5)" + xmlns:w1803="http://schemas.microsoft.com/winfx/2006/xaml/presentation?IsApiContractPresent(Windows.Foundation.UniversalApiContract, 6)" xmlns:viewmodels="using:UniSky.ViewModels" xmlns:mux="using:Microsoft.UI.Xaml.Controls" xmlns:feeds="using:UniSky.ViewModels.Feeds" - xmlns:extensions="using:UniSky.Extensions" + xmlns:extensions="using:UniSky.Extensions" mc:Ignorable="d" d:DataContext="{d:DesignInstance Type=viewmodels:FeedsViewModel}" Background="{ThemeResource ApplicationPageBackgroundThemeBrush}" - NavigationCacheMode="Enabled"> + NavigationCacheMode="Enabled" + w1803:KeyboardAcceleratorPlacementMode="Hidden"> + + + this.Add(new PostViewModel(item)); }); - if (posts.Count == 0) + if (posts.Count == 0 || string.IsNullOrWhiteSpace(this.cursor)) HasMoreItems = false; return new LoadMoreItemsResult() { Count = (uint)posts.Count }; From 66cf06ddf59dc65530e5f225f043cb8e25b3e633 Mon Sep 17 00:00:00 2001 From: Thomas May Date: Sun, 24 Nov 2024 23:36:12 +0000 Subject: [PATCH 07/16] IT TWEETS --- .../Compose/ComposeDiscardDraftDialog.xaml | 16 ++++ .../Compose/ComposeDiscardDraftDialog.xaml.cs | 35 +++++++ UniSky/Controls/Compose/ComposeSheet.xaml | 93 ++++++++++++++----- UniSky/Controls/Compose/ComposeSheet.xaml.cs | 8 +- UniSky/Controls/Sheet/SheetControl.cs | 23 ++++- UniSky/Controls/Sheet/SheetRootControl.xaml | 8 +- UniSky/Converters/Static.cs | 3 +- UniSky/Package.appxmanifest | 2 +- UniSky/Templates/SheetControlStyles.xaml | 6 +- UniSky/Templates/TextBoxStyles.xaml | 8 +- UniSky/ViewModels/Compose/ComposeViewModel.cs | 39 +++++++- 11 files changed, 194 insertions(+), 47 deletions(-) create mode 100644 UniSky/Controls/Compose/ComposeDiscardDraftDialog.xaml create mode 100644 UniSky/Controls/Compose/ComposeDiscardDraftDialog.xaml.cs diff --git a/UniSky/Controls/Compose/ComposeDiscardDraftDialog.xaml b/UniSky/Controls/Compose/ComposeDiscardDraftDialog.xaml new file mode 100644 index 0000000..ae4cd74 --- /dev/null +++ b/UniSky/Controls/Compose/ComposeDiscardDraftDialog.xaml @@ -0,0 +1,16 @@ + + + + diff --git a/UniSky/Controls/Compose/ComposeDiscardDraftDialog.xaml.cs b/UniSky/Controls/Compose/ComposeDiscardDraftDialog.xaml.cs new file mode 100644 index 0000000..51434c5 --- /dev/null +++ b/UniSky/Controls/Compose/ComposeDiscardDraftDialog.xaml.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices.WindowsRuntime; +using Windows.Foundation; +using Windows.Foundation.Collections; +using Windows.UI.Xaml; +using Windows.UI.Xaml.Controls; +using Windows.UI.Xaml.Controls.Primitives; +using Windows.UI.Xaml.Data; +using Windows.UI.Xaml.Input; +using Windows.UI.Xaml.Media; +using Windows.UI.Xaml.Navigation; + +// The Content Dialog item template is documented at https://go.microsoft.com/fwlink/?LinkId=234238 + +namespace UniSky.Controls.Compose +{ + public sealed partial class ComposeDiscardDraftDialog : ContentDialog + { + public ComposeDiscardDraftDialog() + { + this.InitializeComponent(); + } + + private void ContentDialog_PrimaryButtonClick(ContentDialog sender, ContentDialogButtonClickEventArgs args) + { + } + + private void ContentDialog_SecondaryButtonClick(ContentDialog sender, ContentDialogButtonClickEventArgs args) + { + } + } +} diff --git a/UniSky/Controls/Compose/ComposeSheet.xaml b/UniSky/Controls/Compose/ComposeSheet.xaml index ba27705..2398315 100644 --- a/UniSky/Controls/Compose/ComposeSheet.xaml +++ b/UniSky/Controls/Compose/ComposeSheet.xaml @@ -5,11 +5,12 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local="using:UniSky.Controls.Compose" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" - xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:converters="using:UniSky.Converters" mc:Ignorable="d" d:DesignHeight="300" d:DesignWidth="320" Style="{StaticResource DefaultSheetControlStyle}" DataContext="{x:Bind ViewModel, Mode=OneWay}" - PrimaryButtonCommand="{x:Bind ViewModel.HideCommand}" + PrimaryButtonCommand="{x:Bind ViewModel.PostCommand}" + IsPrimaryButtonEnabled="{x:Bind ViewModel.CanPost, Mode=OneWay}" SecondaryButtonCommand="{x:Bind ViewModel.HideCommand}"> @@ -22,33 +23,77 @@ - + + + + + + + + + + - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/UniSky/Controls/Compose/ComposeSheet.xaml.cs b/UniSky/Controls/Compose/ComposeSheet.xaml.cs index b99fa7a..b3c47ec 100644 --- a/UniSky/Controls/Compose/ComposeSheet.xaml.cs +++ b/UniSky/Controls/Compose/ComposeSheet.xaml.cs @@ -55,13 +55,7 @@ private async void OnHiding(SheetControl sender, SheetHidingEventArgs e) var deferral = e.GetDeferral(); try { - var dialog = new MessageDialog("The current post will be lost!", "Discard post?"); - var yesCommand = new UICommand("Yes"); - var noCommand = new UICommand("No"); - dialog.Commands.Add(yesCommand); - dialog.Commands.Add(noCommand); - - if ((await dialog.ShowAsync()) == noCommand) + if (ViewModel.IsDirty && await new ComposeDiscardDraftDialog().ShowAsync() != ContentDialogResult.Primary) { e.Cancel = true; return; diff --git a/UniSky/Controls/Sheet/SheetControl.cs b/UniSky/Controls/Sheet/SheetControl.cs index da3a269..053d88c 100644 --- a/UniSky/Controls/Sheet/SheetControl.cs +++ b/UniSky/Controls/Sheet/SheetControl.cs @@ -124,6 +124,15 @@ public ICommand PrimaryButtonCommand public static readonly DependencyProperty PrimaryButtonCommandProperty = DependencyProperty.Register("PrimaryButtonCommand", typeof(ICommand), typeof(SheetControl), new PropertyMetadata(null)); + public bool IsPrimaryButtonEnabled + { + get { return (bool)GetValue(IsPrimaryButtonEnabledProperty); } + set { SetValue(IsPrimaryButtonEnabledProperty, value); } + } + + public static readonly DependencyProperty IsPrimaryButtonEnabledProperty = + DependencyProperty.Register("IsPrimaryButtonEnabled", typeof(bool), typeof(SheetControl), new PropertyMetadata(true)); + public object SecondaryButtonContent { get => (object)GetValue(SecondaryButtonContentProperty); @@ -160,13 +169,21 @@ public ICommand SecondaryButtonCommand public static readonly DependencyProperty SecondaryButtonCommandProperty = DependencyProperty.Register("SecondaryButtonCommand", typeof(ICommand), typeof(SheetControl), new PropertyMetadata(null)); + public bool IsSecondaryButtonEnabled + { + get { return (bool)GetValue(IsSecondaryButtonEnabledProperty); } + set { SetValue(IsSecondaryButtonEnabledProperty, value); } + } + + public static readonly DependencyProperty IsSecondaryButtonEnabledProperty = + DependencyProperty.Register("IsSecondaryButtonEnabled", typeof(bool), typeof(SheetControl), new PropertyMetadata(true)); + public event TypedEventHandler Showing; public event TypedEventHandler Hiding; public SheetControl() { this.DefaultStyleKey = typeof(SheetControl); - this.Loaded += OnLoaded; } internal void InvokeShowing() @@ -183,9 +200,5 @@ internal async Task InvokeHidingAsync() return !ev.Cancel; } - - private void OnLoaded(object sender, RoutedEventArgs e) - { - } } } diff --git a/UniSky/Controls/Sheet/SheetRootControl.xaml b/UniSky/Controls/Sheet/SheetRootControl.xaml index d4b39c1..9ab353a 100644 --- a/UniSky/Controls/Sheet/SheetRootControl.xaml +++ b/UniSky/Controls/Sheet/SheetRootControl.xaml @@ -147,14 +147,14 @@ To="1" Storyboard.TargetName="CompositionBackdropContainer" Storyboard.TargetProperty="Opacity" - Duration="00:00:00.5" + Duration="00:00:00.3" EasingFunction="{StaticResource ExponentialEaseEnter}" EnableDependentAnimation="True"/> @@ -206,14 +206,14 @@ To="0" Storyboard.TargetName="CompositionBackdropContainer" Storyboard.TargetProperty="Opacity" - Duration="00:00:00.5" + Duration="00:00:00.3" EasingFunction="{StaticResource ExponentialEaseEnter}" EnableDependentAnimation="True"/> diff --git a/UniSky/Converters/Static.cs b/UniSky/Converters/Static.cs index 45df4bc..c06d66a 100644 --- a/UniSky/Converters/Static.cs +++ b/UniSky/Converters/Static.cs @@ -12,7 +12,8 @@ public static bool Equals(int a, int b) => a == b; public static bool AtLeast(int a, int b) => a >= b; - + public static bool Not(bool x) + => !x; public static bool NotNull(object x) => x is not null; diff --git a/UniSky/Package.appxmanifest b/UniSky/Package.appxmanifest index c4b8ca9..d7b999f 100644 --- a/UniSky/Package.appxmanifest +++ b/UniSky/Package.appxmanifest @@ -10,7 +10,7 @@ + Version="1.0.116.0" /> diff --git a/UniSky/Templates/SheetControlStyles.xaml b/UniSky/Templates/SheetControlStyles.xaml index a3bb6a1..54b721a 100644 --- a/UniSky/Templates/SheetControlStyles.xaml +++ b/UniSky/Templates/SheetControlStyles.xaml @@ -44,7 +44,8 @@ Padding="{ThemeResource ButtonPadding}" Grid.Column="2" Visibility="{TemplateBinding PrimaryButtonVisibility}" - Command="{TemplateBinding PrimaryButtonCommand}"> + Command="{TemplateBinding PrimaryButtonCommand}" + IsEnabled="{TemplateBinding IsPrimaryButtonEnabled}"> @@ -54,7 +55,8 @@ Style="{ThemeResource TextBlockButtonStyle}" Grid.Column="0" Visibility="{TemplateBinding SecondaryButtonVisibility}" - Command="{TemplateBinding SecondaryButtonCommand}"> + Command="{TemplateBinding SecondaryButtonCommand}" + IsEnabled="{TemplateBinding IsSecondaryButtonEnabled}"> diff --git a/UniSky/Templates/TextBoxStyles.xaml b/UniSky/Templates/TextBoxStyles.xaml index 2465847..a3605ec 100644 --- a/UniSky/Templates/TextBoxStyles.xaml +++ b/UniSky/Templates/TextBoxStyles.xaml @@ -36,7 +36,13 @@ - + + + + + + + diff --git a/UniSky/ViewModels/Compose/ComposeViewModel.cs b/UniSky/ViewModels/Compose/ComposeViewModel.cs index 9f40335..2d31689 100644 --- a/UniSky/ViewModels/Compose/ComposeViewModel.cs +++ b/UniSky/ViewModels/Compose/ComposeViewModel.cs @@ -7,21 +7,33 @@ using CommunityToolkit.Mvvm.DependencyInjection; using CommunityToolkit.Mvvm.Input; using FishyFlip.Lexicon.App.Bsky.Actor; +using FishyFlip.Lexicon.App.Bsky.Feed; using FishyFlip.Tools; using Microsoft.Extensions.Logging; +using UniSky.Extensions; using UniSky.Services; +using UniSky.ViewModels.Error; namespace UniSky.ViewModels.Compose; public partial class ComposeViewModel : ViewModelBase { [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanPost))] private string _text; [ObservableProperty] private string _avatarUrl; - private IProtocolService protocolService; - private ILogger logger; + private readonly IProtocolService protocolService; + private readonly ILogger logger; + + // TODO: this but better + public bool IsDirty + => !string.IsNullOrEmpty(Text); + + // TODO: ditto + public bool CanPost + => !string.IsNullOrEmpty(Text); public ComposeViewModel( IProtocolService protocolService, @@ -33,6 +45,29 @@ public ComposeViewModel( Task.Run(LoadAsync); } + [RelayCommand] + private async Task Post() + { + Error = null; + using var ctx = this.GetLoadingContext(); + + try + { + var text = Text; + + var post = (await protocolService.Protocol.CreatePostAsync(new Post(text)) + .ConfigureAwait(false)) + .HandleResult(); + + Text = null; + syncContext.Post(async () => { await Hide(); }); + } + catch (Exception ex) + { + syncContext.Post(() => Error = new ExceptionViewModel(ex)); + } + } + [RelayCommand] private async Task Hide() { From 0a38b8fbecf5fc2b4786064379dfacd5c702b54a Mon Sep 17 00:00:00 2001 From: Thomas May Date: Sun, 24 Nov 2024 23:39:01 +0000 Subject: [PATCH 08/16] Include The Shit --- UniSky/Package.appxmanifest | 2 +- UniSky/UniSky.csproj | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/UniSky/Package.appxmanifest b/UniSky/Package.appxmanifest index d7b999f..376869d 100644 --- a/UniSky/Package.appxmanifest +++ b/UniSky/Package.appxmanifest @@ -10,7 +10,7 @@ + Version="1.0.117.0" /> diff --git a/UniSky/UniSky.csproj b/UniSky/UniSky.csproj index 930b96b..a741b19 100644 --- a/UniSky/UniSky.csproj +++ b/UniSky/UniSky.csproj @@ -140,6 +140,9 @@ App.xaml + + ComposeDiscardDraftDialog.xaml + ComposeSheet.xaml @@ -362,6 +365,10 @@ MSBuild:Compile Designer + + MSBuild:Compile + Designer + Designer MSBuild:Compile From ea33180860218b8da675a22ec6d6309b73fc5b04 Mon Sep 17 00:00:00 2001 From: Thomas May Date: Mon, 25 Nov 2024 00:00:10 +0000 Subject: [PATCH 09/16] Fix AccentButton styles --- UniSky/Pages/LoginPage.xaml | 3 +- UniSky/Pages/ProfilePage.xaml | 1 - UniSky/Templates/ButtonStyles.xaml | 87 ++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 3 deletions(-) diff --git a/UniSky/Pages/LoginPage.xaml b/UniSky/Pages/LoginPage.xaml index d5a7762..057cc33 100644 --- a/UniSky/Pages/LoginPage.xaml +++ b/UniSky/Pages/LoginPage.xaml @@ -139,8 +139,7 @@ + + + + + , ISupportI private readonly ATDid did; private readonly string filterType; private readonly IProtocolService protocolService; + private readonly HashSet ids = []; + private string cursor; public FeedItemCollection(FeedViewModel parent, FeedType type, ATUri uri, IProtocolService protocolService) @@ -57,6 +59,7 @@ public async Task RefreshAsync() try { this.cursor = null; + this.ids.Clear(); await dispatcher.RunAsync(CoreDispatcherPriority.Normal, () => this.Clear()); await InternalLoadMoreItemsAsync(25); } @@ -137,7 +140,27 @@ private async Task InternalLoadMoreItemsAsync(int count) await dispatcher.RunAsync(CoreDispatcherPriority.Normal, () => { foreach (var item in posts) - this.Add(new PostViewModel(item)); + { + if (item.Reply is null || item.Reason is ReasonRepost) + { + if (!ids.Contains(item.Post.Cid)) + Add(new PostViewModel(item)); + } + else + { + var reply = item.Reply; + var root = (PostView)reply.Root; + var parent = (PostView)reply.Parent; + + if (!ids.Contains(parent.Cid)) + { + Add(new PostViewModel(parent, true)); + Add(new PostViewModel(item, true)); + + ids.Add(parent.Cid); + } + } + } }); if (posts.Count == 0 || string.IsNullOrWhiteSpace(this.cursor)) diff --git a/UniSky/ViewModels/Post/PostViewModel.cs b/UniSky/ViewModels/Post/PostViewModel.cs index de30bbf..b4539fe 100644 --- a/UniSky/ViewModels/Post/PostViewModel.cs +++ b/UniSky/ViewModels/Post/PostViewModel.cs @@ -14,7 +14,6 @@ using FishyFlip.Models; using FishyFlip.Tools; using Humanizer; -using Org.BouncyCastle.Asn1.Cms; using UniSky.Helpers; using UniSky.Pages; using UniSky.Services; @@ -57,12 +56,23 @@ public partial class PostViewModel : ViewModelBase [ObservableProperty] private ProfileViewModel retweetedBy; [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowReplyContainer))] private ProfileViewModel replyTo; [ObservableProperty] [NotifyPropertyChangedFor(nameof(HasEmbed))] private PostEmbedViewModel embed; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowReplyContainer))] + private bool hasParent; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(Borders))] + private bool hasChild; + public Thickness Borders + => HasChild ? new Thickness() : new Thickness(0, 0, 0, 1); + public string Likes => ToNumberString(LikeCount); public string Retweets @@ -73,9 +83,16 @@ public string Replies public bool HasEmbed => Embed != null; - public PostViewModel(FeedViewPost feedPost) + public bool ShowReplyContainer + => ReplyTo != null && !HasParent; + public bool ShowReplyLine + => HasChild; + + public PostViewModel(FeedViewPost feedPost, bool hasParent = false) : this(feedPost.Post) { + HasParent = hasParent; + if (feedPost.Reason is ReasonRepost { By: ProfileViewBasic { } by }) { RetweetedBy = new ProfileViewModel(by); @@ -87,7 +104,7 @@ public PostViewModel(FeedViewPost feedPost) } } - public PostViewModel(PostView view) + public PostViewModel(PostView view, bool hasChild = false) { if (view.Record is not Post post) throw new InvalidOperationException(); @@ -95,6 +112,8 @@ public PostViewModel(PostView view) this.view = view; this.post = post; + HasChild = hasChild; + Author = new ProfileViewModel(view.Author); Text = post.Text; Embed = CreateEmbedViewModel(view.Embed); From f180a314693f968d3304e87f72540b6784dd4342 Mon Sep 17 00:00:00 2001 From: Thomas May Date: Tue, 26 Nov 2024 23:12:01 +0000 Subject: [PATCH 13/16] Initial reply work --- UniSky/Controls/Compose/ComposeSheet.xaml | 115 +++++++++++++----- UniSky/Controls/Compose/ComposeSheet.xaml.cs | 23 ++-- UniSky/Controls/Sheet/SheetControl.cs | 16 ++- UniSky/Controls/Sheet/SheetControl.xaml | 9 +- .../Controls/Sheet/SheetRootControl.xaml.cs | 4 +- UniSky/DataTemplates/FeedTemplates.xaml | 3 +- UniSky/Package.appxmanifest | 2 +- UniSky/Services/ISheetService.cs | 2 +- UniSky/Services/SheetService.cs | 4 +- UniSky/ViewModels/Compose/ComposeViewModel.cs | 41 ++++++- UniSky/ViewModels/Post/PostViewModel.cs | 14 ++- 11 files changed, 177 insertions(+), 56 deletions(-) diff --git a/UniSky/Controls/Compose/ComposeSheet.xaml b/UniSky/Controls/Compose/ComposeSheet.xaml index e679f3e..0bf10ea 100644 --- a/UniSky/Controls/Compose/ComposeSheet.xaml +++ b/UniSky/Controls/Compose/ComposeSheet.xaml @@ -37,7 +37,7 @@ - + @@ -61,42 +61,93 @@ VerticalAlignment="Top" Background="Transparent" IsIndeterminate="{x:Bind ViewModel.IsLoading, Mode=OneWay}" /> - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + (Ioc.Default); this.Showing += OnShowing; this.Shown += OnShown; this.Hiding += OnHiding; } - private void OnShown(SheetControl sender, RoutedEventArgs args) - { - PrimaryTextBox.Focus(FocusState.Programmatic); - } - - private void OnShowing(SheetControl sender, RoutedEventArgs e) + private void OnShowing(SheetControl sender, SheetShowingEventArgs e) { var inputPane = InputPane.GetForCurrentView(); inputPane.Showing += OnInputPaneShowing; inputPane.Hiding += OnInputPaneHiding; + + if (e.Parameter is PostViewModel replyTo) + { + this.ViewModel = ActivatorUtilities.CreateInstance(Ioc.Default, replyTo); + } + else + { + this.ViewModel = ActivatorUtilities.CreateInstance(Ioc.Default); + } + } + + private void OnShown(SheetControl sender, RoutedEventArgs args) + { + PrimaryTextBox.Focus(FocusState.Programmatic); } private async void OnHiding(SheetControl sender, SheetHidingEventArgs e) diff --git a/UniSky/Controls/Sheet/SheetControl.cs b/UniSky/Controls/Sheet/SheetControl.cs index 71c2ba2..c3cb7ed 100644 --- a/UniSky/Controls/Sheet/SheetControl.cs +++ b/UniSky/Controls/Sheet/SheetControl.cs @@ -22,6 +22,16 @@ namespace UniSky.Controls.Sheet { + public class SheetShowingEventArgs : RoutedEventArgs + { + public object Parameter { get; } + + public SheetShowingEventArgs(object parameter) + { + Parameter = parameter; + } + } + public class SheetHidingEventArgs : RoutedEventArgs { private Deferral _deferral; @@ -178,7 +188,7 @@ public bool IsSecondaryButtonEnabled public static readonly DependencyProperty IsSecondaryButtonEnabledProperty = DependencyProperty.Register("IsSecondaryButtonEnabled", typeof(bool), typeof(SheetControl), new PropertyMetadata(true)); - public event TypedEventHandler Showing; + public event TypedEventHandler Showing; public event TypedEventHandler Shown; public event TypedEventHandler Hiding; public event TypedEventHandler Hidden; @@ -188,9 +198,9 @@ public SheetControl() this.DefaultStyleKey = typeof(SheetControl); } - internal void InvokeShowing() + internal void InvokeShowing(object parameter) { - Showing?.Invoke(this, new RoutedEventArgs()); + Showing?.Invoke(this, new SheetShowingEventArgs(parameter)); } internal async Task InvokeHidingAsync() diff --git a/UniSky/Controls/Sheet/SheetControl.xaml b/UniSky/Controls/Sheet/SheetControl.xaml index c298a38..4be087c 100644 --- a/UniSky/Controls/Sheet/SheetControl.xaml +++ b/UniSky/Controls/Sheet/SheetControl.xaml @@ -3,7 +3,8 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local="using:UniSky" xmlns:sheets="using:UniSky.Controls.Sheet" - xmlns:extensions="using:UniSky.Extensions"> + xmlns:extensions="using:UniSky.Extensions" + xmlns:w1709="http://schemas.microsoft.com/winfx/2006/xaml/presentation?IsApiContractPresent(Windows.Foundation.UniversalApiContract, 5)"> diff --git a/UniSky/ViewModels/Compose/ComposeViewModel.cs b/UniSky/ViewModels/Compose/ComposeViewModel.cs index 9a1dfdf..4648e4d 100644 --- a/UniSky/ViewModels/Compose/ComposeViewModel.cs +++ b/UniSky/ViewModels/Compose/ComposeViewModel.cs @@ -70,26 +70,7 @@ private async Task PostAsync() try { var text = Text; - - ReplyRefDef replyRef = null; - if (ReplyTo != null) - { - var replyPost = ReplyTo.View; - var replyRecord = (await protocolService.Protocol.GetRecordAsync(replyPost.Uri.Did, replyPost.Uri.Collection, replyPost.Uri.Rkey, replyPost.Cid) - .ConfigureAwait(false)) - .HandleResult(); - - if (replyRecord.Value is not Post replyPostFetched) - throw new InvalidOperationException(); - - var replyPostReplyDef = replyPostFetched.Reply; - - replyRef = new ReplyRefDef() - { - Root = replyPostReplyDef?.Root ?? new StrongRef() { Uri = replyPost.Uri, Cid = replyPost.Cid }, - Parent = new StrongRef() { Uri = replyPost.Uri, Cid = replyPost.Cid } - }; - } + var replyRef = await GetReplyDefAsync().ConfigureAwait(false); var postModel = new Post(text, reply: replyRef); var post = (await protocolService.Protocol.CreatePostAsync(postModel) @@ -101,10 +82,35 @@ private async Task PostAsync() } catch (Exception ex) { - syncContext.Post(() => Error = new ExceptionViewModel(ex)); + this.SetErrored(ex); } } + private async Task GetReplyDefAsync() + { + ReplyRefDef replyRef = null; + if (ReplyTo == null) + return replyRef; + + var replyPost = ReplyTo.View; + var replyRecord = (await protocolService.Protocol.GetRecordAsync(replyPost.Uri.Did, replyPost.Uri.Collection, replyPost.Uri.Rkey, replyPost.Cid) + .ConfigureAwait(false)) + .HandleResult(); + + if (replyRecord.Value is not Post replyPostFetched) + throw new InvalidOperationException("Trying to reply to something that isn't a post?"); + + var replyPostReplyDef = replyPostFetched.Reply; + + replyRef = new ReplyRefDef() + { + Root = replyPostReplyDef?.Root ?? new StrongRef() { Uri = replyPost.Uri, Cid = replyPost.Cid }, + Parent = new StrongRef() { Uri = replyPost.Uri, Cid = replyPost.Cid } + }; + + return replyRef; + } + [RelayCommand] private async Task Hide() { diff --git a/UniSky/ViewModels/Feeds/FeedItemCollection.cs b/UniSky/ViewModels/Feeds/FeedItemCollection.cs index b947967..6d060f2 100644 --- a/UniSky/ViewModels/Feeds/FeedItemCollection.cs +++ b/UniSky/ViewModels/Feeds/FeedItemCollection.cs @@ -141,15 +141,10 @@ await dispatcher.RunAsync(CoreDispatcherPriority.Normal, () => { foreach (var item in posts) { - if (item.Reply is null || item.Reason is ReasonRepost) - { - if (!ids.Contains(item.Post.Cid)) - Add(new PostViewModel(item)); - } - else + if (item.Reply is { Parent: PostView } && item.Reason is not ReasonRepost) { var reply = item.Reply; - var root = (PostView)reply.Root; + //var root = (PostView)reply.Root; var parent = (PostView)reply.Parent; if (!ids.Contains(parent.Cid)) @@ -160,6 +155,11 @@ await dispatcher.RunAsync(CoreDispatcherPriority.Normal, () => ids.Add(parent.Cid); } } + else + { + if (!ids.Contains(item.Post.Cid)) + Add(new PostViewModel(item)); + } } }); diff --git a/UniSky/ViewModels/Feeds/FeedViewModel.cs b/UniSky/ViewModels/Feeds/FeedViewModel.cs index 9d627e3..448843f 100644 --- a/UniSky/ViewModels/Feeds/FeedViewModel.cs +++ b/UniSky/ViewModels/Feeds/FeedViewModel.cs @@ -49,6 +49,7 @@ public FeedViewModel(FeedType type, ATUri? id, GeneratorView? record, IProtocolS public async Task RefreshAsync(Deferral? deferral = null) { + this.Error = null; await this.Items.RefreshAsync(); deferral?.Complete(); } From 48b2eeb6975d208c1c3d16fec1c43fdb31c60b9c Mon Sep 17 00:00:00 2001 From: Thomas May Date: Tue, 3 Dec 2024 12:49:57 +0000 Subject: [PATCH 15/16] Hairline border improvements --- UniSky/Extensions/HairlineBorder.cs | 33 +++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/UniSky/Extensions/HairlineBorder.cs b/UniSky/Extensions/HairlineBorder.cs index dbb5737..862a881 100644 --- a/UniSky/Extensions/HairlineBorder.cs +++ b/UniSky/Extensions/HairlineBorder.cs @@ -24,15 +24,27 @@ public static void SetThickness(DependencyObject obj, Thickness value) public static readonly DependencyProperty ThicknessProperty = DependencyProperty.RegisterAttached("Thickness", typeof(Thickness), typeof(HairlineBorder), new PropertyMetadata(new Thickness(), OnThicknessPropertyChanged)); + private static List> Elements { get; set; } = []; + private static DisplayInformation DisplayInfo { get; set; } + private static void OnThicknessPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { if (d is not (Grid or StackPanel or Control or Border)) return; + Elements.Add(new WeakReference(d)); + + if (DisplayInfo == null) + { + Initialize(); + } + var newValue = (Thickness)(e.NewValue); + Apply(d, newValue, DisplayInfo); + } - // TODO: this should be faster - var info = DisplayInformation.GetForCurrentView(); + private static void Apply(DependencyObject d, Thickness newValue, DisplayInformation info) + { var hairlineThickness = (1.0 / (info.LogicalDpi / 96.0)); var thickness = new Thickness( newValue.Left != 0 ? hairlineThickness : 0, @@ -56,5 +68,22 @@ private static void OnThicknessPropertyChanged(DependencyObject d, DependencyPro break; } } + + private static void Initialize() + { + DisplayInfo = DisplayInformation.GetForCurrentView(); + DisplayInfo.DpiChanged += OnDpiChanged; + } + + private static void OnDpiChanged(DisplayInformation sender, object args) + { + foreach (var item in Elements) + { + if (!item.TryGetTarget(out var element) || element.GetValue(ThicknessProperty) is not Thickness thickness) + continue; + + Apply(element, thickness, sender); + } + } } } From d7d426c9acc67f2c4c20e68409c96c7f49ed7c41 Mon Sep 17 00:00:00 2001 From: Thomas May Date: Tue, 3 Dec 2024 16:46:18 +0000 Subject: [PATCH 16/16] Final compose fixes --- UniSky/App.xaml | 18 +- UniSky/App.xaml.cs | 9 +- UniSky/Controls/Compose/ComposeSheet.xaml | 147 +++++++------ UniSky/Controls/Sheet/SheetControl.xaml | 78 ++++--- UniSky/Controls/Sheet/SheetRootControl.xaml | 54 +++-- UniSky/DataTemplates/FeedTemplates.xaml | 34 ++- UniSky/Extensions/Hairline.cs | 193 ++++++++++++++++++ UniSky/Extensions/HairlineBorder.cs | 89 -------- .../Localisation/ShortTimespanFormatter.cs | 28 +++ UniSky/Pages/FeedsPage.xaml | 6 +- UniSky/Pages/HomePage.xaml | 6 +- UniSky/Pages/ProfilePage.xaml | 27 +-- UniSky/Resources/en-GB/Resources.resw | 24 +++ UniSky/Templates/ButtonStyles.xaml | 4 +- UniSky/Templates/NavigationViewStyles.xaml | 3 +- UniSky/UniSky.csproj | 3 +- UniSky/ViewModels/Post/PostViewModel.cs | 7 + 17 files changed, 468 insertions(+), 262 deletions(-) create mode 100644 UniSky/Extensions/Hairline.cs delete mode 100644 UniSky/Extensions/HairlineBorder.cs create mode 100644 UniSky/Helpers/Localisation/ShortTimespanFormatter.cs diff --git a/UniSky/App.xaml b/UniSky/App.xaml index d23ab64..ad310bd 100644 --- a/UniSky/App.xaml +++ b/UniSky/App.xaml @@ -28,12 +28,22 @@ - #10FFFFFF + #FF404040 + + - + #FFDEDEDE #10000000 + + @@ -45,7 +55,7 @@ 64 38--> - 0,1,0,0 + 1 0,0,0,0 14 @@ -54,6 +64,8 @@ + + diff --git a/UniSky/Pages/HomePage.xaml b/UniSky/Pages/HomePage.xaml index af6d89e..0c0ece4 100644 --- a/UniSky/Pages/HomePage.xaml +++ b/UniSky/Pages/HomePage.xaml @@ -172,7 +172,7 @@ HorizontalAlignment="Stretch" VerticalAlignment="Stretch" MaxWidth="600" - BorderBrush="{StaticResource SystemControlSeparatorBrush}" + BorderBrush="{StaticResource SystemControlRevealSeparatorBrush}" ui:UIElementExtensions.ClipToBounds="True"> @@ -364,7 +364,7 @@ - + @@ -375,7 +375,7 @@ - + diff --git a/UniSky/Pages/ProfilePage.xaml b/UniSky/Pages/ProfilePage.xaml index 1c3c484..a8c0553 100644 --- a/UniSky/Pages/ProfilePage.xaml +++ b/UniSky/Pages/ProfilePage.xaml @@ -29,6 +29,7 @@ + 0 @@ -561,6 +561,7 @@ + diff --git a/UniSky/UniSky.csproj b/UniSky/UniSky.csproj index a06a24a..6e8fe48 100644 --- a/UniSky/UniSky.csproj +++ b/UniSky/UniSky.csproj @@ -156,7 +156,7 @@ - + @@ -189,6 +189,7 @@ + diff --git a/UniSky/ViewModels/Post/PostViewModel.cs b/UniSky/ViewModels/Post/PostViewModel.cs index 08f6184..e5e8052 100644 --- a/UniSky/ViewModels/Post/PostViewModel.cs +++ b/UniSky/ViewModels/Post/PostViewModel.cs @@ -39,6 +39,9 @@ public partial class PostViewModel : ViewModelBase [ObservableProperty] private ProfileViewModel author; + [ObservableProperty] + private string date; + [ObservableProperty] [NotifyPropertyChangedFor(nameof(Likes))] private int likeCount; @@ -122,6 +125,10 @@ public PostViewModel(PostView view, bool hasChild = false) Text = post.Text; Embed = CreateEmbedViewModel(view.Embed); + var timeSinceIndex = DateTime.Now - (view.IndexedAt.Value.ToLocalTime()); + var date = timeSinceIndex.Humanize(1, minUnit: Humanizer.Localisation.TimeUnit.Second); + Date = date; + LikeCount = (int)(view.LikeCount ?? 0); RetweetCount = (int)(view.RepostCount ?? 0); ReplyCount = (int)(view.ReplyCount ?? 0);