diff --git a/Gallery.Share/App.cs b/Gallery.Share/App.cs index 106229b..7f5e265 100644 --- a/Gallery.Share/App.cs +++ b/Gallery.Share/App.cs @@ -6,6 +6,7 @@ using Gallery.Util; using Gallery.Resources.Theme; using System.Collections.Generic; using Gallery.Util.Interface; +using Gallery.Resources.UI; namespace Gallery { @@ -26,12 +27,15 @@ namespace Gallery Preferences.Set(Config.IsProxiedKey, true); Preferences.Set(Config.ProxyHostKey, "192.168.25.9"); Preferences.Set(Config.ProxyPortKey, 1081); - - DependencyService.Register(); } private void InitResource() { + foreach (var source in GallerySources) + { + source.InitDynamicResources(Definition.IconSolidFamily, LightTheme.Instance, DarkTheme.Instance); + } + var theme = AppInfo.RequestedTheme; SetTheme(theme, true); } diff --git a/Gallery.Share/AppShell.xaml b/Gallery.Share/AppShell.xaml index 44a59fe..77dd9ef 100644 --- a/Gallery.Share/AppShell.xaml +++ b/Gallery.Share/AppShell.xaml @@ -1,62 +1,34 @@ - - + xmlns:local="clr-namespace:Gallery" + xmlns:r="clr-namespace:Gallery.Resources" + xmlns:ui="clr-namespace:Gallery.Resources.UI" + xmlns:util="clr-namespace:Gallery.Util;assembly=Gallery.Util" + x:Class="Gallery.AppShell" + x:Name="appShell" + BackgroundColor="{DynamicResource NavigationColor}" + FlyoutBackgroundColor="{DynamicResource WindowColor}" + x:DataType="{x:Type local:AppShell}" + BindingContext="{x:Reference appShell}"> - - + --> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - + - - + + --> diff --git a/Gallery.Share/AppShell.xaml.cs b/Gallery.Share/AppShell.xaml.cs index 2553471..fff358e 100644 --- a/Gallery.Share/AppShell.xaml.cs +++ b/Gallery.Share/AppShell.xaml.cs @@ -1,4 +1,7 @@ using System; +using Gallery.Resources; +using Gallery.Resources.UI; +using Gallery.Util; using Gallery.Views; using Xamarin.Forms; @@ -6,16 +9,52 @@ namespace Gallery { public partial class AppShell : Shell { + public static new AppShell Current => Shell.Current as AppShell; + + public static Thickness NavigationBarOffset { get; private set; } + public static Thickness TotalBarOffset { get; private set; } + public AppShell() { InitializeComponent(); - Routing.RegisterRoute(nameof(ItemDetailPage), typeof(ItemDetailPage)); - Routing.RegisterRoute(nameof(NewItemPage), typeof(NewItemPage)); + +#if DEBUG + Log.Print($"folder: {Store.PersonalFolder}"); + Log.Print($"cache: {Store.CacheFolder}"); +#endif + InitFlyouts(); } - private async void OnMenuItemClicked(object sender, EventArgs e) + private void InitFlyouts() { - await Current.GoToAsync("//LoginPage"); + foreach (var source in App.GallerySources) + { + var s = source; + var tab = new Tab + { + Title = source.Name, + Route = source.Route, + Items = + { + new ShellContent + { + ContentTemplate = new DataTemplate(() => new GalleryPage(s)) + } + } + } + .DynamicResource(BaseShellItem.FlyoutIconProperty, source.FlyoutIconKey); + flyoutItems.Items.Add(tab); + } + } + + public void SetNavigationBarHeight(double height) + { + NavigationBarOffset = new Thickness(0, height, 0, 0); + } + + public void SetStatusBarHeight(double navigation, double height) + { + TotalBarOffset = new Thickness(0, navigation + height, 0, 0); } } } diff --git a/Gallery.Share/Gallery.Share.projitems b/Gallery.Share/Gallery.Share.projitems index b0715d4..44f58a0 100644 --- a/Gallery.Share/Gallery.Share.projitems +++ b/Gallery.Share/Gallery.Share.projitems @@ -10,30 +10,6 @@ - - - - - - - - - - - Views\AboutPage.xaml - - - Views\ItemDetailPage.xaml - - - Views\ItemsPage.xaml - - - Views\LoginPage.xaml - - - Views\NewItemPage.xaml - AppShell.xaml @@ -44,11 +20,17 @@ + + + + + + + GalleryPage.xaml + - - @@ -56,32 +38,14 @@ - - Designer - MSBuild:UpdateDesignTimeXaml - - - Designer - MSBuild:UpdateDesignTimeXaml - - - Designer - MSBuild:UpdateDesignTimeXaml - - - Designer - MSBuild:UpdateDesignTimeXaml - - - Designer - MSBuild:UpdateDesignTimeXaml - Designer MSBuild:UpdateDesignTimeXaml - - - + + Designer + MSBuild:UpdateDesignTimeXaml + + \ No newline at end of file diff --git a/Gallery.Share/Models/Item.cs b/Gallery.Share/Models/Item.cs deleted file mode 100644 index 098c8e8..0000000 --- a/Gallery.Share/Models/Item.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System; - -namespace Gallery.Models -{ - public class Item - { - public string Id { get; set; } - public string Text { get; set; } - public string Description { get; set; } - } -} diff --git a/Gallery.Share/Resources/Converters.cs b/Gallery.Share/Resources/Converters.cs new file mode 100644 index 0000000..20be4a2 --- /dev/null +++ b/Gallery.Share/Resources/Converters.cs @@ -0,0 +1,22 @@ +using System; +using System.Globalization; +using Gallery.Resources.UI; +using Xamarin.Forms; + +namespace Gallery.Resources +{ + public class FavoriteIconConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + return value == null ? + Definition.IconLove : + Definition.IconCircleLove; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } +} diff --git a/Gallery.Share/Resources/Helper.cs b/Gallery.Share/Resources/Helper.cs index b121f2d..c7f85fa 100644 --- a/Gallery.Share/Resources/Helper.cs +++ b/Gallery.Share/Resources/Helper.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Reflection; using System.Xml; using Gallery.Util; +using Xamarin.Forms; using Xamarin.Forms.Xaml; namespace Gallery.Resources @@ -84,6 +85,7 @@ namespace Gallery.Resources } } + [ContentProperty(nameof(Text))] public class TextExtension : IMarkupExtension { public string Text { get; set; } diff --git a/Gallery.Share/Resources/Theme/DarkTheme.cs b/Gallery.Share/Resources/Theme/DarkTheme.cs index 1c0fab2..d1d4e95 100644 --- a/Gallery.Share/Resources/Theme/DarkTheme.cs +++ b/Gallery.Share/Resources/Theme/DarkTheme.cs @@ -27,9 +27,16 @@ namespace Gallery.Resources.Theme private void InitColors() { Add(StatusBarStyle, StatusBarStyles.WhiteText); + Add(WindowColor, Color.Black); + Add(TintColor, Color.FromRgb(0x94, 0x95, 0x9a)); + Add(TextColor, Color.White); + Add(SubTextColor, Color.LightGray); + Add(CardBackgroundColor, Color.FromRgb(0x33, 0x33, 0x33)); Add(NavigationColor, Color.FromRgb(0x11, 0x11, 0x11)); + Add(NavigationSelectedColor, Color.FromRgb(0x22, 0x22, 0x22)); + Add(OptionBackColor, Color.Black); + Add(OptionTintColor, Color.FromRgb(0x11, 0x11, 0x11)); - Add(Primary, Color.FromRgb(33, 150, 243)); } } } diff --git a/Gallery.Share/Resources/Theme/LightTheme.cs b/Gallery.Share/Resources/Theme/LightTheme.cs index 1dc9e3a..2e23760 100644 --- a/Gallery.Share/Resources/Theme/LightTheme.cs +++ b/Gallery.Share/Resources/Theme/LightTheme.cs @@ -27,9 +27,16 @@ namespace Gallery.Resources.Theme private void InitColors() { Add(StatusBarStyle, StatusBarStyles.DarkText); + Add(WindowColor, Color.White); + Add(TintColor, Color.FromRgb(0x87, 0x87, 0x8b)); // 0x7f, 0x99, 0xc6 + Add(TextColor, Color.Black); + Add(SubTextColor, Color.DimGray); + Add(CardBackgroundColor, Color.FromRgb(0xf3, 0xf3, 0xf3)); Add(NavigationColor, Color.FromRgb(0xf0, 0xf0, 0xf0)); + Add(NavigationSelectedColor, Color.LightGray); + Add(OptionBackColor, Color.FromRgb(0xf0, 0xf0, 0xf0)); + Add(OptionTintColor, Color.White); - Add(Primary, Color.FromRgb(33, 150, 243)); } } } diff --git a/Gallery.Share/Resources/Theme/Theme.cs b/Gallery.Share/Resources/Theme/Theme.cs index fc20c58..bb21106 100644 --- a/Gallery.Share/Resources/Theme/Theme.cs +++ b/Gallery.Share/Resources/Theme/Theme.cs @@ -6,7 +6,15 @@ namespace Gallery.Resources.Theme public abstract class Theme : ResourceDictionary { public const string StatusBarStyle = nameof(StatusBarStyle); + public const string WindowColor = nameof(WindowColor); + public const string TintColor = nameof(TintColor); + public const string TextColor = nameof(TextColor); + public const string SubTextColor = nameof(SubTextColor); + public const string CardBackgroundColor = nameof(CardBackgroundColor); public const string NavigationColor = nameof(NavigationColor); + public const string NavigationSelectedColor = nameof(NavigationSelectedColor); + public const string OptionBackColor = nameof(OptionBackColor); + public const string OptionTintColor = nameof(OptionTintColor); public const string IconLightFamily = nameof(IconLightFamily); public const string IconRegularFamily = nameof(IconRegularFamily); @@ -16,8 +24,6 @@ namespace Gallery.Resources.Theme public const string IconClose = nameof(IconClose); public const string FontIconRefresh = nameof(FontIconRefresh); - public const string Primary = nameof(Primary); - protected void InitResources() { Add(IconLightFamily, Definition.IconLightFamily); diff --git a/Gallery.Share/Resources/UI/AdaptedPage.cs b/Gallery.Share/Resources/UI/AdaptedPage.cs new file mode 100644 index 0000000..b620665 --- /dev/null +++ b/Gallery.Share/Resources/UI/AdaptedPage.cs @@ -0,0 +1,148 @@ +using System; +using Gallery.Services; +using Gallery.Util; +using Xamarin.Essentials; +using Xamarin.Forms; + +namespace Gallery.Resources.UI +{ + public class AdaptedPage : ContentPage + { + public static readonly BindableProperty TopMarginProperty = BindableProperty.Create(nameof(TopMargin), typeof(Thickness), typeof(AdaptedPage)); + + public Thickness TopMargin + { + get => (Thickness)GetValue(TopMarginProperty); + set => SetValue(TopMarginProperty, value); + } + + public event EventHandler Load; + public event EventHandler Unload; + + protected static readonly bool isPhone = DeviceInfo.Idiom == DeviceIdiom.Phone; + + public AdaptedPage() + { + SetDynamicResource(Screen.StatusBarStyleProperty, Theme.Theme.StatusBarStyle); + Shell.SetNavBarHasShadow(this, true); + } + + public virtual void OnLoad() => Load?.Invoke(this, EventArgs.Empty); + + public virtual void OnUnload() => Unload?.Invoke(this, EventArgs.Empty); + + public virtual void OnOrientationChanged(bool landscape) + { + var old = TopMargin; + Thickness @new; + if (Definition.IsFullscreenDevice) + { + @new = landscape ? + AppShell.NavigationBarOffset : + AppShell.TotalBarOffset; + } + else if (isPhone) + { + @new = landscape ? + Definition.TopOffset32 : + AppShell.TotalBarOffset; + } + else + { + // iPad + @new = AppShell.TotalBarOffset; + } + if (old != @new) + { + TopMargin = @new; + OnTopMarginChanged(old, @new); + } + } + + protected virtual void OnTopMarginChanged(Thickness old, Thickness @new) { } + + protected override void OnSizeAllocated(double width, double height) + { + base.OnSizeAllocated(width, height); + OnOrientationChanged(width > height); + } + + protected void AnimateToMargin(View element, Thickness margin, bool animate = true) + { + var m = margin; + var start = element.Margin.Top - m.Top; + element.Margin = m; + element.CancelAnimations(); + if (start > 0 && animate) + { + element.Animate("margin", top => + { + element.TranslationY = top; + }, + start, 0, +#if DEBUG + length: 500, +#else + length: 300, +#endif + easing: Easing.SinInOut, + finished: (v, r) => + { + element.TranslationY = 0; + }); + } + else if (element.TranslationY != 0) + { + element.TranslationY = 0; + } + } + + protected void Start(Action action) + { + if (Tap.IsBusy) + { + Log.Error($"{GetType()}.tap", "gesture recognizer is now busy..."); + return; + } + using (Tap.Start()) + { + action(); + } + } + + private class Tap : IDisposable + { + public static bool IsBusy + { + get + { + lock (sync) + { + return instance?.isBusy == true; + } + } + } + + private static readonly object sync = new(); + private static readonly Tap instance = new(); + + private Tap() { } + + public static Tap Start() + { + lock (sync) + { + instance.isBusy = true; + } + return instance; + } + + private bool isBusy = false; + + public void Dispose() + { + isBusy = false; + } + } + } +} diff --git a/Gallery.Share/Resources/UI/CardView.cs b/Gallery.Share/Resources/UI/CardView.cs new file mode 100644 index 0000000..9a905c6 --- /dev/null +++ b/Gallery.Share/Resources/UI/CardView.cs @@ -0,0 +1,44 @@ +using Gallery.Util.Model; +using Xamarin.Forms; + +namespace Gallery.Resources.UI +{ + public class CardView : ContentView + { + public static readonly BindableProperty CornerRadiusProperty = BindableProperty.Create(nameof(CornerRadius), typeof(float), typeof(CardView)); + public static readonly BindableProperty ShadowColorProperty = BindableProperty.Create(nameof(ShadowColor), typeof(Color), typeof(CardView)); + public static readonly BindableProperty ShadowRadiusProperty = BindableProperty.Create(nameof(ShadowRadius), typeof(float), typeof(CardView), 3f); + public static readonly BindableProperty ShadowOffsetProperty = BindableProperty.Create(nameof(ShadowOffset), typeof(Size), typeof(CardView)); + + public float CornerRadius + { + get => (float)GetValue(CornerRadiusProperty); + set => SetValue(CornerRadiusProperty, value); + } + public Color ShadowColor + { + get => (Color)GetValue(ShadowColorProperty); + set => SetValue(ShadowColorProperty, value); + } + public float ShadowRadius + { + get => (float)GetValue(ShadowRadiusProperty); + set => SetValue(ShadowRadiusProperty, value); + } + public Size ShadowOffset + { + get => (Size)GetValue(ShadowOffsetProperty); + set => SetValue(ShadowOffsetProperty, value); + } + + protected override SizeRequest OnMeasure(double widthConstraint, double heightConstraint) + { + if (BindingContext is GalleryItem item && + item.Width > 0 && item.ImageHeight.IsAuto) + { + item.ImageHeight = widthConstraint * item.Height / item.Width; + } + return base.OnMeasure(widthConstraint, heightConstraint); + } + } +} diff --git a/Gallery.Share/Resources/UI/Definition.cs b/Gallery.Share/Resources/UI/Definition.cs index 9e85ad1..8cd30fa 100644 --- a/Gallery.Share/Resources/UI/Definition.cs +++ b/Gallery.Share/Resources/UI/Definition.cs @@ -10,6 +10,12 @@ namespace Gallery.Resources.UI public const double FontSizeTitle = 18.0; public static readonly Thickness ScreenBottomPadding; + public static readonly Thickness TopOffset32 = new(0, 32, 0, 0); + public static readonly Color ColorLightShadow = Color.FromRgba(0, 0, 0, 0x20); + public static readonly Color ColorRedBackground = Color.FromRgb(0xfd, 0x43, 0x63); + public static readonly Color ColorDownloadBackground = Color.FromRgb(0xd7, 0xd9, 0xe0); + public static readonly ImageSource DownloadBackground = ImageSource.FromFile("download.png"); + public static readonly double FontSizeSmall = Device.GetNamedSize(NamedSize.Small, typeof(Label)); #if __IOS__ public const string IconLightFamily = "FontAwesome5Pro-Light"; @@ -26,6 +32,8 @@ namespace Gallery.Resources.UI #endif public const string IconRefresh = "\uf2f9"; + public const string IconLove = "\uf004"; + public const string IconCircleLove = "\uf4c7"; public const string IconClose = "\uf057"; static Definition() diff --git a/Gallery.Share/Resources/UI/GalleryCollectionPage.cs b/Gallery.Share/Resources/UI/GalleryCollectionPage.cs new file mode 100644 index 0000000..06202ac --- /dev/null +++ b/Gallery.Share/Resources/UI/GalleryCollectionPage.cs @@ -0,0 +1,557 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Gallery.Services; +using Gallery.Util; +using Gallery.Util.Interface; +using Gallery.Util.Model; +using Xamarin.Forms; + +namespace Gallery.Resources.UI +{ + public abstract class GalleryCollectionPage : GalleryScrollableCollectionPage + { + protected readonly IGallerySource source; + + public GalleryCollectionPage(IGallerySource source) + { + this.source = source; + } + } + + public interface IGalleryCollectionPage + { + GalleryCollection GalleryCollection { get; set; } + } + + public abstract class GalleryCollectionPage : AdaptedPage, IGalleryCollectionPage + { + const int EXPIRED_MINUTES = 5; + + protected const double loadingOffset = -40; + + public static readonly BindableProperty GalleryProperty = BindableProperty.Create(nameof(Gallery), typeof(GalleryCollection), typeof(GalleryCollectionPage)); + public static readonly BindableProperty ColumnsProperty = BindableProperty.Create(nameof(Columns), typeof(int), typeof(GalleryCollectionPage), + defaultValue: 2); + public static readonly BindableProperty IsLoadingProperty = BindableProperty.Create(nameof(IsLoading), typeof(bool), typeof(GalleryCollectionPage), + defaultValue: true); + public static readonly BindableProperty IsBottomLoadingProperty = BindableProperty.Create(nameof(IsBottomLoading), typeof(bool), typeof(GalleryCollectionPage)); + + public GalleryCollection Gallery + { + get => (GalleryCollection)GetValue(GalleryProperty); + set => SetValue(GalleryProperty, value); + } + public int Columns + { + get => (int)GetValue(ColumnsProperty); + set => SetValue(ColumnsProperty, value); + } + public bool IsLoading + { + get => (bool)GetValue(IsLoadingProperty); + set => SetValue(IsLoadingProperty, value); + } + public bool IsBottomLoading + { + get => (bool)GetValue(IsBottomLoadingProperty); + set => SetValue(IsBottomLoadingProperty, value); + } + + public GalleryCollection GalleryCollection { get; set; } + + protected virtual ActivityIndicator LoadingIndicator => null; + protected virtual double IndicatorMarginTop => 16; + + protected bool Expired => lastUpdated == default || (DateTime.Now - lastUpdated).TotalMinutes > EXPIRED_MINUTES; + + protected readonly Command commandGalleryItemTapped; + protected DateTime lastUpdated; + protected double topOffset; + protected string lastError; + + private readonly object sync = new(); + private readonly Stack tasks = new(); + private T galleryData; + + public GalleryCollectionPage() + { + commandGalleryItemTapped = new Command(OnGalleryItemTapped); + } + + private void OnGalleryItemTapped(GalleryItem item) + { + if (item == null) + { + return; + } + //Start(async () => + //{ + // var page = new GalleryItemPage(item); + // await Navigation.PushAsync(page); + //}); + } + + public override void OnUnload() + { + lock (sync) + { + while (tasks.TryPop(out var task)) + { + if (task != null) + { + task.Dispose(); + } + } + } + InvalidateCollection(); + Gallery = null; + lastUpdated = default; + } + + protected override void OnAppearing() + { + base.OnAppearing(); + + if (lastUpdated == default) + { + StartLoading(); + } + } + +#if __IOS__ + public override void OnOrientationChanged(bool landscape) + { + base.OnOrientationChanged(landscape); + + if (Definition.IsFullscreenDevice) + { + topOffset = landscape ? + AppShell.NavigationBarOffset.Top : + AppShell.TotalBarOffset.Top; + } + else if (isPhone) + { + topOffset = landscape ? + Definition.TopOffset32.Top : + AppShell.TotalBarOffset.Top; + } + else + { + // iPad + topOffset = AppShell.TotalBarOffset.Top; + } + } +#endif + + protected override void OnSizeAllocated(double width, double height) + { + base.OnSizeAllocated(width, height); + + int columns; + if (width > height) + { + columns = isPhone ? 4 : 6; + } + else + { + columns = isPhone ? 2 : 4; + } + if (Columns != columns) + { + Columns = columns; +#if DEBUG + Log.Print($"changing columns to {columns}"); +#endif + } + } + + protected abstract Task DoloadGalleryData(bool force); + protected abstract IEnumerable DoGetGalleryList(T data, out int tag); + + protected virtual GalleryCollection FilterGalleryCollection(GalleryCollection collection, bool bottom) + { + GalleryCollection = collection; + return collection; + } + + protected void InvalidateCollection() + { + var collection = GalleryCollection; + if (collection != null) + { + collection.Running = false; + GalleryCollection = null; + } + } + + protected virtual void StartLoading(bool force = false, bool isBottom = false) + { + if (force || Expired) + { + var indicator = LoadingIndicator; + if (indicator == null || isBottom) + { + if (isBottom) + { + IsBottomLoading = true; + } + else + { + InvalidateCollection(); + IsLoading = true; + } +#if __IOS__ + Device.StartTimer(TimeSpan.FromMilliseconds(48), () => +#else + Device.StartTimer(TimeSpan.FromMilliseconds(150), () => +#endif + { + _ = DoloadGallerySource(force, isBottom); + return false; + }); + } + else + { + InvalidateCollection(); + IsLoading = true; + + var offset = 16 - IndicatorMarginTop; + indicator.CancelAnimations(); + indicator.Animate("margin", top => + { + indicator.Margin = new Thickness(0, top, 0, offset); + }, + loadingOffset - offset, 16 - offset, + easing: Easing.CubicOut, + finished: (v, r) => + { + _ = DoloadGallerySource(force, isBottom); + }); + } + } + } + + protected virtual void DoGalleryLoaded(GalleryCollection collection, bool bottom) + { + collection = FilterGalleryCollection(collection, bottom); + + var indicator = LoadingIndicator; + if (indicator == null || bottom) + { + IsLoading = false; + IsBottomLoading = false; +#if __IOS__ + Device.StartTimer(TimeSpan.FromMilliseconds(48), () => +#else + Device.StartTimer(TimeSpan.FromMilliseconds(150), () => +#endif + { + Gallery = collection; + return false; + }); + } + else + { + var offset = 16 - IndicatorMarginTop; + indicator.CancelAnimations(); + indicator.Animate("margin", top => + { + indicator.Margin = new Thickness(0, top, 0, offset); + }, + 16 - offset, loadingOffset - offset, + easing: Easing.CubicIn, + finished: (v, r) => + { + indicator.Margin = new Thickness(0, v, 0, offset); + IsLoading = false; + IsBottomLoading = false; +#if __IOS__ + Device.StartTimer(TimeSpan.FromMilliseconds(48), () => +#else + Device.StartTimer(TimeSpan.FromMilliseconds(150), () => +#endif + { + Gallery = collection; + return false; + }); + }); + } + } + + protected async Task ScrollToTopAsync(ScrollView scrollView) + { + if (scrollView.ScrollY > -topOffset) + { +#if __IOS__ + await scrollView.ScrollToAsync(scrollView.ScrollX, -topOffset, true); +#else + await scrollView.ScrollToAsync(0, -topOffset, false); +#endif + } + } + + protected DataTemplate GetCardViewTemplate(string titleBinding = null) + { + return new DataTemplate(() => + { + var image = new RoundImage + { + BackgroundColor = Definition.ColorDownloadBackground, + CornerRadius = 10, + CornerMasks = CornerMask.Top, + HorizontalOptions = LayoutOptions.Fill, + Aspect = Aspect.AspectFill, + GestureRecognizers = + { + new TapGestureRecognizer + { + Command = commandGalleryItemTapped + } + .Binding(TapGestureRecognizer.CommandParameterProperty, ".") + } + } + .Binding(Image.SourceProperty, nameof(GalleryItem.PreviewImage)); + + var title = new Label + { + Padding = new Thickness(8, 2), + HorizontalOptions = LayoutOptions.FillAndExpand, + VerticalOptions = LayoutOptions.Center, + LineBreakMode = LineBreakMode.TailTruncation, + FontSize = Definition.FontSizeSmall + } + .DynamicResource(Label.TextColorProperty, Theme.Theme.TextColor); + + var favorite = new Label + { + WidthRequest = 26, + HorizontalOptions = LayoutOptions.End, + HorizontalTextAlignment = TextAlignment.End, + VerticalOptions = LayoutOptions.Center, + FontSize = Definition.FontSizeSmall, + TextColor = Definition.ColorRedBackground, + IsVisible = false + } + .Binding(Label.TextProperty, nameof(GalleryItem.BookmarkId), converter: new FavoriteIconConverter()) + .Binding(IsVisibleProperty, nameof(GalleryItem.IsFavorite)) + .DynamicResource(Label.FontFamilyProperty, Theme.Theme.IconSolidFamily); + + return new CardView + { + Padding = 0, + Margin = 0, + CornerRadius = 10, + ShadowColor = Definition.ColorLightShadow, + ShadowOffset = new Size(1, 1), + Content = new Grid + { + HorizontalOptions = LayoutOptions.Fill, + RowSpacing = 0, + RowDefinitions = + { + new RowDefinition().Binding(RowDefinition.HeightProperty, nameof(GalleryItem.ImageHeight)), + new RowDefinition { Height = 30 } + }, + Children = + { + image, + new Grid + { + ColumnDefinitions = + { + new ColumnDefinition(), + new ColumnDefinition { Width = 20 } + }, + VerticalOptions = LayoutOptions.Center, + Padding = new Thickness(0, 0, 8, 0), + Children = + { + title.Binding(Label.TextProperty, titleBinding ?? nameof(GalleryItem.TagDescription)), + favorite.GridColumn(1) + } + } + .GridRow(1) + } + } + } + .DynamicResource(BackgroundColorProperty, Theme.Theme.CardBackgroundColor); + }); + } + + protected async Task DoloadGallerySource(bool force = false, bool bottom = false) + { +#if DEBUG + Log.Print($"start loading data, force: {force}"); +#endif + galleryData = await DoloadGalleryData(force); + if (galleryData == null) + { + Log.Error("gallery.load", "failed to load gallery data."); + return; + } + if (force) + { + lastUpdated = DateTime.Now; + } + + var data = DoGetGalleryList(galleryData, out int tag).Where(i => i != null); + var collection = new GalleryCollection(data); + foreach (var item in collection) + { + if (item.PreviewImage == null) + { + var image = await Store.LoadPreviewImage(item.PreviewUrl, false); + if (image != null) + { + item.PreviewImage = image; + } + } + } + + DoGalleryLoaded(collection, bottom); + DoloadImages(collection, tag); + } + + private void DoloadImages(GalleryCollection collection, int tag) + { + lock (sync) + { + if (tasks.TryPeek(out var peek)) + { + if (peek != null && peek.TagIndex >= tag) + { + Log.Print($"tasks expired ({tasks.Count}, peek: {peek.TagIndex}, now: {tag}, will be disposed."); + while (tasks.TryPop(out var t)) + { + t?.Dispose(); + } + } + } + } + var list = collection.Where(i => i.PreviewImage == null).ToArray(); + var task = ParallelTask.Start("collection.load", 0, list.Length, 2, i => + { + if (!collection.Running) + { + return false; + } + var item = list[i]; + if (item.PreviewImage == null && item.PreviewUrl != null) + { + item.PreviewImage = Definition.DownloadBackground; + var image = Store.LoadPreviewImage(item.PreviewUrl, true, force: true).Result; + if (image != null) + { + item.PreviewImage = image; + } + } + return true; + }, tagIndex: tag); + + if (task != null) + { + lock (sync) + { + tasks.Push(task); + } + } + } + } + + public abstract class GalleryScrollableCollectionPage : GalleryCollectionPage + { + protected const int SCROLL_OFFSET = 33; + protected ScrollDirection scrollDirection = ScrollDirection.Stop; + protected double lastScrollY = double.MinValue; + + private double lastRefreshY = double.MinValue; + private double offset; + + protected bool IsScrollingDown(double y) + { + if (y > lastScrollY) + { + if (scrollDirection != ScrollDirection.Down) + { + scrollDirection = ScrollDirection.Down; + } + return true; + } + else + { + if (scrollDirection != ScrollDirection.Up) + { + scrollDirection = ScrollDirection.Up; + } + return false; + } + } + + protected void SetOffset(double off) + { + offset = off; + } + + protected abstract bool CheckRefresh(); + + protected override void StartLoading(bool force = false, bool isBottom = false) + { + if (!isBottom) + { + lastRefreshY = double.MinValue; + } + base.StartLoading(force, isBottom); + } + + protected override GalleryCollection FilterGalleryCollection(GalleryCollection collection, bool bottom) + { + var now = GalleryCollection; + if (now == null) + { + now = collection; + GalleryCollection = now; + } + else + { + now.AddRange(collection); + } + return now; + } + + protected void OnScrolled(double y) + { + lastScrollY = y; + if (scrollDirection == ScrollDirection.Up) + { + return; + } + if (y > 0 && offset > 0 && y - topOffset > offset) + { + if (IsLoading || IsBottomLoading) + { + return; + } + if (y - lastRefreshY > 200) + { + if (CheckRefresh()) + { + lastRefreshY = y; +#if DEBUG + Log.Print("start to load next page"); +#endif + StartLoading(true, true); + } + } + } + } + } + + public enum ScrollDirection + { + Stop, + Up, + Down + } +} diff --git a/Gallery.Share/Services/GalleryCollection.cs b/Gallery.Share/Services/GalleryCollection.cs new file mode 100644 index 0000000..940c9b2 --- /dev/null +++ b/Gallery.Share/Services/GalleryCollection.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using Gallery.Resources.UI; +using Gallery.Util.Model; +using Xamarin.Essentials; + +namespace Gallery.Services +{ + public class GalleryCollection : List, ICollectionChanged + { + private static GalleryCollection empty; + + public static GalleryCollection Empty + { + get + { + if (empty == null) + { + empty = new GalleryCollection(); + } + return empty; + } + } + + public event EventHandler CollectionChanged; + + public bool Running { get; set; } + + public GalleryCollection() : base() + { + Running = true; + } + + public GalleryCollection(IEnumerable gallery) : base(gallery) + { + Running = true; + } + + public void AddRange(List items) + { + var e = new CollectionChangedEventArgs + { + NewStartingIndex = Count, + NewItems = items + }; + base.AddRange(items); + if (MainThread.IsMainThread) + { + CollectionChanged?.Invoke(this, e); + } + else + { + MainThread.BeginInvokeOnMainThread(() => CollectionChanged?.Invoke(this, e)); + } + } + } +} diff --git a/Gallery.Share/Services/IDataStore.cs b/Gallery.Share/Services/IDataStore.cs deleted file mode 100644 index efed17d..0000000 --- a/Gallery.Share/Services/IDataStore.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading.Tasks; - -namespace Gallery.Services -{ - public interface IDataStore - { - Task AddItemAsync(T item); - Task UpdateItemAsync(T item); - Task DeleteItemAsync(string id); - Task GetItemAsync(string id); - Task> GetItemsAsync(bool forceRefresh = false); - } -} diff --git a/Gallery.Share/Services/MockDataStore.cs b/Gallery.Share/Services/MockDataStore.cs deleted file mode 100644 index fc5ca91..0000000 --- a/Gallery.Share/Services/MockDataStore.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Gallery.Models; - -namespace Gallery.Services -{ - public class MockDataStore : IDataStore - { - readonly List items; - - public MockDataStore() - { - items = new List() - { - new Item { Id = Guid.NewGuid().ToString(), Text = "First item", Description="This is an item description." }, - new Item { Id = Guid.NewGuid().ToString(), Text = "Second item", Description="This is an item description." }, - new Item { Id = Guid.NewGuid().ToString(), Text = "Third item", Description="This is an item description." }, - new Item { Id = Guid.NewGuid().ToString(), Text = "Fourth item", Description="This is an item description." }, - new Item { Id = Guid.NewGuid().ToString(), Text = "Fifth item", Description="This is an item description." }, - new Item { Id = Guid.NewGuid().ToString(), Text = "Sixth item", Description="This is an item description." } - }; - } - - public async Task AddItemAsync(Item item) - { - items.Add(item); - - return await Task.FromResult(true); - } - - public async Task UpdateItemAsync(Item item) - { - var oldItem = items.Where((Item arg) => arg.Id == item.Id).FirstOrDefault(); - items.Remove(oldItem); - items.Add(item); - - return await Task.FromResult(true); - } - - public async Task DeleteItemAsync(string id) - { - var oldItem = items.Where((Item arg) => arg.Id == id).FirstOrDefault(); - items.Remove(oldItem); - - return await Task.FromResult(true); - } - - public async Task GetItemAsync(string id) - { - return await Task.FromResult(items.FirstOrDefault(s => s.Id == id)); - } - - public async Task> GetItemsAsync(bool forceRefresh = false) - { - return await Task.FromResult(items); - } - } -} diff --git a/Gallery.Share/ViewModels/AboutViewModel.cs b/Gallery.Share/ViewModels/AboutViewModel.cs deleted file mode 100644 index 1fa890d..0000000 --- a/Gallery.Share/ViewModels/AboutViewModel.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System; -using System.Windows.Input; -using Xamarin.Essentials; -using Xamarin.Forms; - -namespace Gallery.ViewModels -{ - public class AboutViewModel : BaseViewModel - { - public AboutViewModel() - { - Title = "About"; - OpenWebCommand = new Command(async () => await Browser.OpenAsync("https://aka.ms/xamarin-quickstart")); - } - - public ICommand OpenWebCommand { get; } - } -} diff --git a/Gallery.Share/ViewModels/BaseViewModel.cs b/Gallery.Share/ViewModels/BaseViewModel.cs deleted file mode 100644 index 04c6118..0000000 --- a/Gallery.Share/ViewModels/BaseViewModel.cs +++ /dev/null @@ -1,56 +0,0 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Runtime.CompilerServices; - -using Xamarin.Forms; - -using Gallery.Models; -using Gallery.Services; - -namespace Gallery.ViewModels -{ - public class BaseViewModel : INotifyPropertyChanged - { - public IDataStore DataStore => DependencyService.Get>(); - - bool isBusy = false; - public bool IsBusy - { - get { return isBusy; } - set { SetProperty(ref isBusy, value); } - } - - string title = string.Empty; - public string Title - { - get { return title; } - set { SetProperty(ref title, value); } - } - - protected bool SetProperty(ref T backingStore, T value, - [CallerMemberName] string propertyName = "", - Action onChanged = null) - { - if (EqualityComparer.Default.Equals(backingStore, value)) - return false; - - backingStore = value; - onChanged?.Invoke(); - OnPropertyChanged(propertyName); - return true; - } - - #region INotifyPropertyChanged - public event PropertyChangedEventHandler PropertyChanged; - protected void OnPropertyChanged([CallerMemberName] string propertyName = "") - { - var changed = PropertyChanged; - if (changed == null) - return; - - changed.Invoke(this, new PropertyChangedEventArgs(propertyName)); - } - #endregion - } -} diff --git a/Gallery.Share/ViewModels/ItemDetailViewModel.cs b/Gallery.Share/ViewModels/ItemDetailViewModel.cs deleted file mode 100644 index e4c7236..0000000 --- a/Gallery.Share/ViewModels/ItemDetailViewModel.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System; -using System.Diagnostics; -using System.Threading.Tasks; -using Gallery.Models; -using Xamarin.Forms; - -namespace Gallery.ViewModels -{ - [QueryProperty(nameof(ItemId), nameof(ItemId))] - public class ItemDetailViewModel : BaseViewModel - { - private string itemId; - private string text; - private string description; - public string Id { get; set; } - - public string Text - { - get => text; - set => SetProperty(ref text, value); - } - - public string Description - { - get => description; - set => SetProperty(ref description, value); - } - - public string ItemId - { - get - { - return itemId; - } - set - { - itemId = value; - LoadItemId(value); - } - } - - public async void LoadItemId(string itemId) - { - try - { - var item = await DataStore.GetItemAsync(itemId); - Id = item.Id; - Text = item.Text; - Description = item.Description; - } - catch (Exception) - { - Debug.WriteLine("Failed to Load Item"); - } - } - } -} diff --git a/Gallery.Share/ViewModels/ItemsViewModel.cs b/Gallery.Share/ViewModels/ItemsViewModel.cs deleted file mode 100644 index 8ae6361..0000000 --- a/Gallery.Share/ViewModels/ItemsViewModel.cs +++ /dev/null @@ -1,86 +0,0 @@ -using System; -using System.Collections.ObjectModel; -using System.Diagnostics; -using System.Threading.Tasks; - -using Xamarin.Forms; - -using Gallery.Models; -using Gallery.Views; - -namespace Gallery.ViewModels -{ - public class ItemsViewModel : BaseViewModel - { - private Item _selectedItem; - - public ObservableCollection Items { get; } - public Command LoadItemsCommand { get; } - public Command AddItemCommand { get; } - public Command ItemTapped { get; } - - public ItemsViewModel() - { - Title = "Browse"; - Items = new ObservableCollection(); - LoadItemsCommand = new Command(async () => await ExecuteLoadItemsCommand()); - - ItemTapped = new Command(OnItemSelected); - - AddItemCommand = new Command(OnAddItem); - } - - async Task ExecuteLoadItemsCommand() - { - IsBusy = true; - - try - { - Items.Clear(); - var items = await DataStore.GetItemsAsync(true); - foreach (var item in items) - { - Items.Add(item); - } - } - catch (Exception ex) - { - Debug.WriteLine(ex); - } - finally - { - IsBusy = false; - } - } - - public void OnAppearing() - { - IsBusy = true; - SelectedItem = null; - } - - public Item SelectedItem - { - get => _selectedItem; - set - { - SetProperty(ref _selectedItem, value); - OnItemSelected(value); - } - } - - private async void OnAddItem(object obj) - { - await Shell.Current.GoToAsync(nameof(NewItemPage)); - } - - async void OnItemSelected(Item item) - { - if (item == null) - return; - - // This will push the ItemDetailPage onto the navigation stack - await Shell.Current.GoToAsync($"{nameof(ItemDetailPage)}?{nameof(ItemDetailViewModel.ItemId)}={item.Id}"); - } - } -} diff --git a/Gallery.Share/ViewModels/LoginViewModel.cs b/Gallery.Share/ViewModels/LoginViewModel.cs deleted file mode 100644 index 1aa8796..0000000 --- a/Gallery.Share/ViewModels/LoginViewModel.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Gallery.Views; -using System; -using System.Collections.Generic; -using System.Text; -using Xamarin.Forms; - -namespace Gallery.ViewModels -{ - public class LoginViewModel : BaseViewModel - { - public Command LoginCommand { get; } - - public LoginViewModel() - { - LoginCommand = new Command(OnLoginClicked); - } - - private async void OnLoginClicked(object obj) - { - // Prefixing with `//` switches to a different navigation stack instead of pushing to the active one - await Shell.Current.GoToAsync($"//{nameof(AboutPage)}"); - } - } -} diff --git a/Gallery.Share/ViewModels/NewItemViewModel.cs b/Gallery.Share/ViewModels/NewItemViewModel.cs deleted file mode 100644 index 6ba6069..0000000 --- a/Gallery.Share/ViewModels/NewItemViewModel.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; -using System.Windows.Input; -using Gallery.Models; -using Xamarin.Forms; - -namespace Gallery.ViewModels -{ - public class NewItemViewModel : BaseViewModel - { - private string text; - private string description; - - public NewItemViewModel() - { - SaveCommand = new Command(OnSave, ValidateSave); - CancelCommand = new Command(OnCancel); - this.PropertyChanged += - (_, __) => SaveCommand.ChangeCanExecute(); - } - - private bool ValidateSave() - { - return !String.IsNullOrWhiteSpace(text) - && !String.IsNullOrWhiteSpace(description); - } - - public string Text - { - get => text; - set => SetProperty(ref text, value); - } - - public string Description - { - get => description; - set => SetProperty(ref description, value); - } - - public Command SaveCommand { get; } - public Command CancelCommand { get; } - - private async void OnCancel() - { - // This will pop the current page off the navigation stack - await Shell.Current.GoToAsync(".."); - } - - private async void OnSave() - { - Item newItem = new Item() - { - Id = Guid.NewGuid().ToString(), - Text = Text, - Description = Description - }; - - await DataStore.AddItemAsync(newItem); - - // This will pop the current page off the navigation stack - await Shell.Current.GoToAsync(".."); - } - } -} diff --git a/Gallery.Share/Views/AboutPage.xaml b/Gallery.Share/Views/AboutPage.xaml deleted file mode 100644 index 9610963..0000000 --- a/Gallery.Share/Views/AboutPage.xaml +++ /dev/null @@ -1,52 +0,0 @@ - - - - - - - - - - #96d1ff - - - - - - - - - - - - - - - - - - - - - - diff --git a/Gallery.Share/Views/NewItemPage.xaml.cs b/Gallery.Share/Views/NewItemPage.xaml.cs deleted file mode 100644 index fff2546..0000000 --- a/Gallery.Share/Views/NewItemPage.xaml.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel; -using Xamarin.Forms; -using Xamarin.Forms.Xaml; - -using Gallery.Models; -using Gallery.ViewModels; - -namespace Gallery.Views -{ - public partial class NewItemPage : ContentPage - { - public Item Item { get; set; } - - public NewItemPage() - { - InitializeComponent(); - BindingContext = new NewItemViewModel(); - } - } -} diff --git a/Gallery.UI/CustomViews.cs b/Gallery.UI/CustomViews.cs new file mode 100644 index 0000000..a20993c --- /dev/null +++ b/Gallery.UI/CustomViews.cs @@ -0,0 +1,8 @@ +using Xamarin.Forms; + +namespace Gallery.Resources.UI +{ + public class BlurryPanel : ContentView { } + + public class CircleImage : Image { } +} diff --git a/Gallery.UI/Extensions.cs b/Gallery.UI/Extensions.cs new file mode 100644 index 0000000..8ab5ff5 --- /dev/null +++ b/Gallery.UI/Extensions.cs @@ -0,0 +1,54 @@ +using Xamarin.Forms; + +namespace Gallery.Resources.UI +{ + public static class Extensions + { + public const string TextColor = nameof(TextColor); + public const string SubTextColor = nameof(SubTextColor); + public const string OptionTintColor = nameof(OptionTintColor); + + public static T Binding(this T view, BindableProperty property, string name, BindingMode mode = BindingMode.Default, IValueConverter converter = null) where T : BindableObject + { + if (name == null) + { + view.SetValue(property, property.DefaultValue); + } + else + { + view.SetBinding(property, name, mode, converter); + } + return view; + } + + public static T DynamicResource(this T view, BindableProperty property, string key) where T : Element + { + view.SetDynamicResource(property, key); + return view; + } + + public static T GridRow(this T view, int row) where T : BindableObject + { + Grid.SetRow(view, row); + return view; + } + + public static T GridRowSpan(this T view, int rowSpan) where T : BindableObject + { + Grid.SetRowSpan(view, rowSpan); + return view; + } + + public static T GridColumn(this T view, int column) where T : BindableObject + { + Grid.SetColumn(view, column); + return view; + } + + public static T GridColumnSpan(this T view, int columnSpan) where T : BindableObject + { + Grid.SetColumnSpan(view, columnSpan); + return view; + } + } +} diff --git a/Gallery.UI/FlowLayout.cs b/Gallery.UI/FlowLayout.cs new file mode 100644 index 0000000..682eb1d --- /dev/null +++ b/Gallery.UI/FlowLayout.cs @@ -0,0 +1,275 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using Xamarin.Forms; + +namespace Gallery.Resources.UI +{ + public class FlowLayout : AbsoluteLayout + { + public static readonly BindableProperty ColumnProperty = BindableProperty.Create(nameof(Column), typeof(int), typeof(FlowLayout), + defaultValue: 2, propertyChanged: OnColumnPropertyChanged); + public static readonly BindableProperty RowSpacingProperty = BindableProperty.Create(nameof(RowSpacing), typeof(double), typeof(FlowLayout), defaultValue: 10.0); + public static readonly BindableProperty ColumnSpacingProperty = BindableProperty.Create(nameof(ColumnSpacing), typeof(double), typeof(FlowLayout), defaultValue: 10.0); + + private static void OnColumnPropertyChanged(BindableObject obj, object old, object @new) + { + var flowLayout = (FlowLayout)obj; + if (old is int column && column != flowLayout.Column) + { + flowLayout.UpdateChildrenLayout(); + //flowLayout.InvalidateLayout(); + } + } + + public static readonly BindableProperty ItemTemplateProperty = BindableProperty.Create(nameof(ItemTemplate), typeof(DataTemplate), typeof(FlowLayout)); + public static readonly BindableProperty ItemsSourceProperty = BindableProperty.Create(nameof(ItemsSource), typeof(IList), typeof(FlowLayout), + propertyChanged: OnItemsSourcePropertyChanged); + + private static void OnItemsSourcePropertyChanged(BindableObject obj, object old, object @new) + { + var flowLayout = (FlowLayout)obj; + if (old is ICollectionChanged oldNotify) + { + oldNotify.CollectionChanged -= flowLayout.OnCollectionChanged; + } + flowLayout.lastWidth = -1; + if (@new == null) + { + flowLayout.freezed = true; + flowLayout.cachedLayout.Clear(); + flowLayout.Children.Clear(); + flowLayout.freezed = false; + + flowLayout.InvalidateLayout(); + } + else if (@new is IList list) + { + flowLayout.freezed = true; + flowLayout.cachedLayout.Clear(); + flowLayout.Children.Clear(); + for (var i = 0; i < list.Count; i++) + { + var child = flowLayout.ItemTemplate.CreateContent(); + if (child is View view) + { + view.BindingContext = list[i]; + flowLayout.Children.Add(view); + } + } + if (@new is ICollectionChanged newNotify) + { + newNotify.CollectionChanged += flowLayout.OnCollectionChanged; + } + flowLayout.freezed = false; + + flowLayout.UpdateChildrenLayout(); + //flowLayout.InvalidateLayout(); + } + } + + public int Column + { + get => (int)GetValue(ColumnProperty); + set => SetValue(ColumnProperty, value); + } + + public double RowSpacing + { + get => (double)GetValue(RowSpacingProperty); + set => SetValue(RowSpacingProperty, value); + } + + public double ColumnSpacing + { + get => (double)GetValue(ColumnSpacingProperty); + set => SetValue(ColumnSpacingProperty, value); + } + + public DataTemplate ItemTemplate + { + get => (DataTemplate)GetValue(ItemTemplateProperty); + set => SetValue(ItemTemplateProperty, value); + } + + public IList ItemsSource + { + get => (IList)GetValue(ItemsSourceProperty); + set => SetValue(ItemsSourceProperty, value); + } + + + public event EventHandler MaxHeightChanged; + + public double ColumnWidth { get; private set; } + + private bool freezed; + private double maximumHeight; + private readonly Dictionary cachedLayout = new(); + + private double lastWidth = -1; + private SizeRequest lastSizeRequest; + + protected override void LayoutChildren(double x, double y, double width, double height) + { + if (freezed) + { + return; + } + var column = Column; + if (column < 1) + { + return; + } + var source = ItemsSource; + if (source == null || source.Count <= 0) + { + return; + } + var columnSpacing = ColumnSpacing; + var rowSpacing = RowSpacing; + + var columnHeights = new double[column]; + var columnSpacingTotal = columnSpacing * (column - 1); + var columnWidth = (width - columnSpacingTotal) / column; + ColumnWidth = columnWidth; + + foreach (var item in Children) + { + var measured = item.Measure(columnWidth, height, flags: MeasureFlags.IncludeMargins); + var col = 0; + for (var i = 1; i < column; i++) + { + if (columnHeights[i] < columnHeights[col]) + { + col = i; + } + } + + var rect = new Rectangle( + col * (columnWidth + columnSpacing), + columnHeights[col], + columnWidth, + measured.Request.Height); + if (cachedLayout.TryGetValue(item, out var r)) + { + if (r != rect) + { + cachedLayout[item] = rect; + item.Layout(rect); + //SetLayoutBounds(item, rect); + } + } + else + { + cachedLayout.Add(item, rect); + item.Layout(rect); + //SetLayoutBounds(item, rect); + } + columnHeights[col] += measured.Request.Height + rowSpacing; + } + } + + protected override SizeRequest OnMeasure(double widthConstraint, double heightConstraint) + { + var column = Column; + if (column < 1) + { + return base.OnMeasure(widthConstraint, heightConstraint); + } + if (lastWidth == widthConstraint) + { + return lastSizeRequest; + } + lastWidth = widthConstraint; + var columnSpacing = ColumnSpacing; + var rowSpacing = RowSpacing; + + var columnHeights = new double[column]; + var columnSpacingTotal = columnSpacing * (column - 1); + var columnWidth = (widthConstraint - columnSpacingTotal) / column; + ColumnWidth = columnWidth; + + foreach (var item in Children) + { + var measured = item.Measure(columnWidth, heightConstraint, flags: MeasureFlags.IncludeMargins); + var col = 0; + for (var i = 1; i < column; i++) + { + if (columnHeights[i] < columnHeights[col]) + { + col = i; + } + } + columnHeights[col] += measured.Request.Height + rowSpacing; + } + maximumHeight = columnHeights.Max(); + + if (maximumHeight > 0) + { + MaxHeightChanged?.Invoke(this, new HeightEventArgs { ContentHeight = maximumHeight }); + } + + lastSizeRequest = new SizeRequest(new Size(widthConstraint, maximumHeight)); + return lastSizeRequest; + } + + private void OnCollectionChanged(object sender, CollectionChangedEventArgs e) + { + lastWidth = -1; + if (e.OldItems != null) + { + freezed = true; + cachedLayout.Clear(); + var index = e.OldStartingIndex; + for (var i = index + e.OldItems.Count - 1; i >= index; i--) + { + Children.RemoveAt(i); + } + freezed = false; + } + + if (e.NewItems == null) + { + UpdateChildrenLayout(); + //InvalidateLayout(); + return; + } + + freezed = true; + var start = e.NewStartingIndex; + for (var i = 0; i < e.NewItems.Count; i++) + { + var child = ItemTemplate.CreateContent(); + if (child is View view) + { + view.BindingContext = e.NewItems[i]; + Children.Insert(start + i, view); + } + } + freezed = false; + + UpdateChildrenLayout(); + //InvalidateLayout(); + } + } + + public interface ICollectionChanged + { + event EventHandler CollectionChanged; + } + + public class CollectionChangedEventArgs : EventArgs + { + public int OldStartingIndex { get; set; } + public IList OldItems { get; set; } + public int NewStartingIndex { get; set; } + public IList NewItems { get; set; } + } + + public class HeightEventArgs : EventArgs + { + public double ContentHeight { get; set; } + } +} diff --git a/Gallery.UI/Gallery.UI.projitems b/Gallery.UI/Gallery.UI.projitems new file mode 100644 index 0000000..edb5abf --- /dev/null +++ b/Gallery.UI/Gallery.UI.projitems @@ -0,0 +1,18 @@ + + + + $(MSBuildAllProjects);$(MSBuildThisFileFullPath) + true + {73AB85FB-D11A-43FB-BBC5-54BED5A056D1} + + + Gallery.Resources.UI + + + + + + + + + \ No newline at end of file diff --git a/Gallery.UI/Gallery.UI.shproj b/Gallery.UI/Gallery.UI.shproj new file mode 100644 index 0000000..6de5074 --- /dev/null +++ b/Gallery.UI/Gallery.UI.shproj @@ -0,0 +1,11 @@ + + + + {73AB85FB-D11A-43FB-BBC5-54BED5A056D1} + + + + + + + \ No newline at end of file diff --git a/Gallery.UI/OptionCells.cs b/Gallery.UI/OptionCells.cs new file mode 100644 index 0000000..7ba861b --- /dev/null +++ b/Gallery.UI/OptionCells.cs @@ -0,0 +1,157 @@ +using System.Collections; +using Xamarin.Forms; + +namespace Gallery.Resources.UI +{ + public class OptionEntry : Entry { } + + public class OptionPicker : Picker { } + + public abstract class OptionCell : ViewCell + { + public static readonly BindableProperty TitleProperty = BindableProperty.Create(nameof(Title), typeof(string), typeof(OptionCell)); + + public string Title + { + get => (string)GetValue(TitleProperty); + set => SetValue(TitleProperty, value); + } + + protected abstract View Content { get; } + + public OptionCell() + { + View = new Grid + { + BindingContext = this, + Padding = new Thickness(20, 0), + ColumnDefinitions = + { + new ColumnDefinition { Width = new GridLength(.3, GridUnitType.Star) }, + new ColumnDefinition { Width = new GridLength(.7, GridUnitType.Star) } + }, + Children = + { + new Label + { + LineBreakMode = LineBreakMode.TailTruncation, + VerticalOptions = LayoutOptions.Center + } + .Binding(Label.TextProperty, nameof(Title)) + .DynamicResource(Label.TextColorProperty, Extensions.TextColor), + + Content.GridColumn(1) + } + } + .DynamicResource(VisualElement.BackgroundColorProperty, Extensions.OptionTintColor); + } + } + + public class OptionTextCell : OptionCell + { + public static readonly BindableProperty DetailProperty = BindableProperty.Create(nameof(Detail), typeof(string), typeof(OptionCell)); + + public string Detail + { + get => (string)GetValue(DetailProperty); + set => SetValue(DetailProperty, value); + } + + protected override View Content => + new Label + { + HorizontalOptions = LayoutOptions.End, + VerticalOptions = LayoutOptions.Center + } + .Binding(Label.TextProperty, nameof(Detail)) + .DynamicResource(Label.TextColorProperty, Extensions.SubTextColor); + } + + public class OptionSwitchCell : OptionCell + { + public static readonly BindableProperty IsToggledProperty = BindableProperty.Create(nameof(IsToggled), typeof(bool), typeof(OptionSwitchCell)); + + public bool IsToggled + { + get => (bool)GetValue(IsToggledProperty); + set => SetValue(IsToggledProperty, value); + } + + protected override View Content => + new Switch + { + HorizontalOptions = LayoutOptions.End, + VerticalOptions = LayoutOptions.Center + } + .Binding(Switch.IsToggledProperty, nameof(IsToggled), mode: BindingMode.TwoWay); + } + + public class OptionDropCell : OptionCell + { + public static readonly BindableProperty ItemsProperty = BindableProperty.Create(nameof(Items), typeof(IList), typeof(OptionDropCell)); + public static readonly BindableProperty SelectedIndexProperty = BindableProperty.Create(nameof(SelectedIndex), typeof(int), typeof(OptionDropCell)); + + public IList Items + { + get => (IList)GetValue(ItemsProperty); + set => SetValue(ItemsProperty, value); + } + + public int SelectedIndex + { + get => (int)GetValue(SelectedIndexProperty); + set => SetValue(SelectedIndexProperty, value); + } + + protected override View Content => + new OptionPicker + { + HorizontalOptions = LayoutOptions.Fill, + VerticalOptions = LayoutOptions.Center + } + .Binding(Picker.ItemsSourceProperty, nameof(Items)) + .Binding(Picker.SelectedIndexProperty, nameof(SelectedIndex), mode: BindingMode.TwoWay) + .DynamicResource(Picker.TextColorProperty, Extensions.TextColor) + .DynamicResource(VisualElement.BackgroundColorProperty, Extensions.OptionTintColor); + } + + public class OptionEntryCell : OptionCell + { + public static readonly BindableProperty TextProperty = BindableProperty.Create(nameof(Text), typeof(string), typeof(OptionEntryCell)); + public static readonly BindableProperty KeyboardProperty = BindableProperty.Create(nameof(Keyboard), typeof(Keyboard), typeof(OptionEntryCell)); + public static readonly BindableProperty PlaceholderProperty = BindableProperty.Create(nameof(Placeholder), typeof(string), typeof(OptionEntryCell)); + + public string Text + { + get => (string)GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public Keyboard Keyboard + { + get => (Keyboard)GetValue(KeyboardProperty); + set => SetValue(KeyboardProperty, value); + } + + public string Placeholder + { + get => (string)GetValue(PlaceholderProperty); + set => SetValue(PlaceholderProperty, value); + } + + protected override View Content => + new OptionEntry + { + HorizontalOptions = LayoutOptions.Fill, + HorizontalTextAlignment = TextAlignment.End, + VerticalOptions = LayoutOptions.Center, + ReturnType = ReturnType.Next + } + .Binding(Entry.TextProperty, nameof(Text), mode: BindingMode.TwoWay) + .Binding(Entry.PlaceholderProperty, nameof(Placeholder)) + .Binding(InputView.KeyboardProperty, nameof(Keyboard)) + .DynamicResource(Entry.TextProperty, Extensions.TextColor) + .DynamicResource(Entry.PlaceholderColorProperty, Extensions.SubTextColor) + .DynamicResource(VisualElement.BackgroundColorProperty, Extensions.OptionTintColor); + } +} diff --git a/Gallery.UI/RoundViews.cs b/Gallery.UI/RoundViews.cs new file mode 100644 index 0000000..8fa45e3 --- /dev/null +++ b/Gallery.UI/RoundViews.cs @@ -0,0 +1,63 @@ +using Xamarin.Forms; + +namespace Gallery.Resources.UI +{ + public class RoundImage : Image + { + public static readonly BindableProperty CornerRadiusProperty = BindableProperty.Create(nameof(CornerRadius), typeof(float), typeof(RoundImage)); + public static readonly BindableProperty CornerMasksProperty = BindableProperty.Create(nameof(CornerMasks), typeof(CornerMask), typeof(RoundImage)); + + public float CornerRadius + { + get => (float)GetValue(CornerRadiusProperty); + set => SetValue(CornerRadiusProperty, value); + } + public CornerMask CornerMasks + { + get => (CornerMask)GetValue(CornerMasksProperty); + set => SetValue(CornerMasksProperty, value); + } + + } + + public class RoundLabel : Label + { + public static readonly BindableProperty CornerRadiusProperty = BindableProperty.Create(nameof(CornerRadius), typeof(float), typeof(RoundLabel)); + public static new readonly BindableProperty BackgroundColorProperty = BindableProperty.Create(nameof(BackgroundColor), typeof(Color), typeof(RoundLabel), Color.Transparent); + + public float CornerRadius + { + get => (float)GetValue(CornerRadiusProperty); + set => SetValue(CornerRadiusProperty, value); + } + public new Color BackgroundColor + { + get => (Color)GetValue(BackgroundColorProperty); + set => SetValue(BackgroundColorProperty, value); + } + } + + public enum CornerMask + { + None = 0, + + LeftTop = 1, + RightTop = 2, + LeftBottom = 4, + RightBottom = 8, + + Top = LeftTop | RightTop, // 3 + Left = LeftTop | LeftBottom, // 5 + Slash = RightTop | LeftBottom, // 6 + BackSlash = LeftTop | RightBottom, // 9 + Right = RightTop | RightBottom, // 10 + Bottom = LeftBottom | RightBottom, // 12 + + ExceptRightBottom = LeftTop | RightTop | LeftBottom, // 7 + ExceptLeftBottom = LeftTop | RightTop | RightBottom, // 11 + ExceptRightTop = LeftTop | LeftBottom | RightBottom, // 13 + ExceptLeftTop = RightTop | LeftBottom | RightBottom, // 14 + + All = LeftTop | RightTop | LeftBottom | RightBottom // 15 + } +} diff --git a/Gallery.Util/Extensions.cs b/Gallery.Util/Extensions.cs index 65579cd..e91ee32 100644 --- a/Gallery.Util/Extensions.cs +++ b/Gallery.Util/Extensions.cs @@ -1,53 +1,9 @@ using System; -using Xamarin.Forms; namespace Gallery.Util { public static class Extensions { - public static T Binding(this T view, BindableProperty property, string name, BindingMode mode = BindingMode.Default, IValueConverter converter = null) where T : BindableObject - { - if (name == null) - { - view.SetValue(property, property.DefaultValue); - } - else - { - view.SetBinding(property, name, mode, converter); - } - return view; - } - - public static T DynamicResource(this T view, BindableProperty property, string key) where T : Element - { - view.SetDynamicResource(property, key); - return view; - } - - public static T GridRow(this T view, int row) where T : BindableObject - { - Grid.SetRow(view, row); - return view; - } - - public static T GridRowSpan(this T view, int rowSpan) where T : BindableObject - { - Grid.SetRowSpan(view, rowSpan); - return view; - } - - public static T GridColumn(this T view, int column) where T : BindableObject - { - Grid.SetColumn(view, column); - return view; - } - - public static T GridColumnSpan(this T view, int columnSpan) where T : BindableObject - { - Grid.SetColumnSpan(view, columnSpan); - return view; - } - public static int IndexOf(this T[] array, Predicate predicate) { for (var i = 0; i < array.Length; i++) diff --git a/Gallery.Util/Interface/IGallerySource.cs b/Gallery.Util/Interface/IGallerySource.cs index 8b5524b..6a489c2 100644 --- a/Gallery.Util/Interface/IGallerySource.cs +++ b/Gallery.Util/Interface/IGallerySource.cs @@ -1,5 +1,6 @@ using System.Threading.Tasks; using Gallery.Util.Model; +using Xamarin.Forms; namespace Gallery.Util.Interface { @@ -7,10 +8,16 @@ namespace Gallery.Util.Interface { string Name { get; } + string Route { get; } + + string FlyoutIconKey { get; } + string HomePage { get; } void SetCookie(); + void InitDynamicResources(string family, ResourceDictionary light, ResourceDictionary dark); + Task GetRecentItemsAsync(int page); } } diff --git a/Gallery.Util/Model/GalleryItem.cs b/Gallery.Util/Model/GalleryItem.cs index b4f5f50..9de299b 100644 --- a/Gallery.Util/Model/GalleryItem.cs +++ b/Gallery.Util/Model/GalleryItem.cs @@ -14,14 +14,41 @@ namespace Gallery.Util.Model public static readonly BindableProperty UserNameProperty = BindableProperty.Create(nameof(UserName), typeof(string), typeof(GalleryItem)); public static readonly BindableProperty CreatedTimeProperty = BindableProperty.Create(nameof(CreatedTime), typeof(DateTime), typeof(GalleryItem)); public static readonly BindableProperty UpdatedTimeProperty = BindableProperty.Create(nameof(UpdatedTime), typeof(DateTime), typeof(GalleryItem)); - public static readonly BindableProperty ImageHeightProperty = BindableProperty.Create(nameof(ImageHeight), typeof(GridLength), typeof(GalleryItem), GridLength.Auto); + public static readonly BindableProperty ImageHeightProperty = BindableProperty.Create(nameof(ImageHeight), typeof(GridLength), typeof(GalleryItem), + defaultValue: GridLength.Auto); + public static readonly BindableProperty IsFavoriteProperty = BindableProperty.Create(nameof(IsFavorite), typeof(bool), typeof(GalleryItem)); + public static readonly BindableProperty BookmarkIdProperty = BindableProperty.Create(nameof(BookmarkId), typeof(string), typeof(GalleryItem)); [JsonIgnore] - public string TagDescription { get; set; } + public string TagDescription + { + get => (string)GetValue(TagDescriptionProperty); + set => SetValue(TagDescriptionProperty, value); + } [JsonIgnore] - public ImageSource PreviewImage { get; set; } + public ImageSource PreviewImage + { + get => (ImageSource)GetValue(PreviewImageProperty); + set => SetValue(PreviewImageProperty, value); + } [JsonIgnore] - public GridLength ImageHeight { get; set; } + public GridLength ImageHeight + { + get => (GridLength)GetValue(ImageHeightProperty); + set => SetValue(ImageHeightProperty, value); + } + [JsonIgnore] + public bool IsFavorite + { + get => (bool)GetValue(IsFavoriteProperty); + set => SetValue(IsFavoriteProperty, value); + } + [JsonIgnore] + public string BookmarkId + { + get => (string)GetValue(BookmarkIdProperty); + set => SetValue(BookmarkIdProperty, value); + } public long Id { get; internal set; } private string[] tags; diff --git a/Gallery.Util/NetHelper.cs b/Gallery.Util/NetHelper.cs index 48e7f65..e5c8df1 100644 --- a/Gallery.Util/NetHelper.cs +++ b/Gallery.Util/NetHelper.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.IO; using System.Net.Http; using System.Net.Http.Headers; using System.Text.Json; @@ -90,6 +92,138 @@ namespace Gallery.Util } } + public static async Task DownloadImage(string url, string working, string folder) + { + try + { + var directory = Path.Combine(working, folder); + if (!Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + var file = Path.Combine(directory, Path.GetFileName(url)); + var response = await Request(url, headers => + { + headers.Add("User-Agent", Config.UserAgent); + headers.Add("Accept", Config.AcceptImage); + }); + if (response == null) + { + return null; + } + using (response) + using (var fs = File.OpenWrite(file)) + { + await response.Content.CopyToAsync(fs); + } + return file; + } + catch (Exception ex) + { + Log.Error("image.download", ex.Message); + return null; + } + } + + public static async Task DownloadImageAsync(string url, string id, string working, string folder) + { + try + { + var directory = Path.Combine(working, folder); + if (!Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + var file = Path.Combine(directory, Path.GetFileName(url)); + var proxy = Config.Proxy; + var handler = new HttpClientHandler + { + UseCookies = false + }; + if (proxy != null) + { + handler.Proxy = proxy; + handler.UseProxy = true; + } + var client = new HttpClient(handler, true) + { + Timeout = Config.Timeout + }; + long size; + DateTimeOffset lastModified; + using (var request = new HttpRequestMessage(HttpMethod.Head, url)) + { + var headers = request.Headers; + headers.Add("Accept", Config.AcceptImage); + headers.Add("Accept-Language", Config.AcceptLanguage); + headers.Add("User-Agent", Config.UserAgent); + using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); + size = response.Content.Headers.ContentLength.Value; + lastModified = response.Content.Headers.LastModified.Value; +#if DEBUG + Log.Print($"content length: {size:n0} bytes, last modified: {lastModified}"); +#endif + } + + // segments + const int SIZE = 150000; + var list = new List<(long from, long to)>(); + for (long i = 0; i < size; i += SIZE) + { + long to; + if (i + SIZE >= size) + { + to = size - 1; + } + else + { + to = i + SIZE - 1; + } + list.Add((i, to)); + } + + var data = new byte[size]; + var task = new TaskCompletionSource(); + + ParallelTask.Start($"download.async.{id}", 0, list.Count, 2, i => + { + var (from, to) = list[i]; + using (var request = new HttpRequestMessage(HttpMethod.Get, url)) + { + var headers = request.Headers; + headers.Add("Accept", Config.AcceptImage); + headers.Add("Accept-Language", Config.AcceptLanguage); + headers.Add("Accept-Encoding", "identity"); + headers.IfRange = new RangeConditionHeaderValue(lastModified); + headers.Range = new RangeHeaderValue(from, to); + headers.Add("User-Agent", Config.UserAgent); + using var response = client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead).Result; + using var ms = new MemoryStream(data, (int)from, (int)(to - from + 1)); + response.Content.CopyToAsync(ms).Wait(); +#if DEBUG + Log.Print($"downloaded range: from({from:n0}) to ({to:n0})"); +#endif + } + return true; + }, + complete: o => + { + using (var fs = File.OpenWrite(file)) + { + fs.Write(data, 0, data.Length); + } + task.SetResult(file); + }); + + return await task.Task; + } + catch (Exception ex) + { + Log.Error("image.download.async", $"failed to download image, error: {ex.Message}"); + return null; + } + } + private static async Task Request(string url, Action headerHandler, HttpContent post = null) { #if DEBUG diff --git a/Gallery.Util/Store.cs b/Gallery.Util/Store.cs index 6aa8dd9..603b256 100644 --- a/Gallery.Util/Store.cs +++ b/Gallery.Util/Store.cs @@ -1,6 +1,9 @@ using System; +using System.IO; using System.Net; +using System.Threading.Tasks; using Xamarin.Essentials; +using Xamarin.Forms; namespace Gallery.Util { @@ -8,6 +11,65 @@ namespace Gallery.Util { public static readonly string PersonalFolder = Environment.GetFolderPath(Environment.SpecialFolder.Personal); public static readonly string CacheFolder = FileSystem.CacheDirectory; + + private const string imageFolder = "img-original"; + private const string previewFolder = "img-preview"; + + public static async Task LoadRawImage(string url) + { + return await LoadImageAsync(url, null, PersonalFolder, imageFolder, force: true); + } + + public static async Task LoadPreviewImage(string url, bool downloading, bool force = false) + { + return await LoadImage(url, CacheFolder, previewFolder, downloading, force: force); + } + + private static async Task LoadImage(string url, string working, string folder, bool downloading, bool force = false) + { + var file = Path.Combine(working, folder, Path.GetFileName(url)); + ImageSource image; + if (!force && File.Exists(file)) + { + image = ImageSource.FromFile(file); + } + else + { + image = null; + } + if (downloading && image == null) + { + file = await NetHelper.DownloadImage(url, working, folder); + if (file != null) + { + return ImageSource.FromFile(file); + } + } + return image; + } + + private static async Task LoadImageAsync(string url, string id, string working, string folder, bool force = false) + { + var file = Path.Combine(working, folder, Path.GetFileName(url)); + ImageSource image; + if (!force && File.Exists(file)) + { + image = ImageSource.FromFile(file); + } + else + { + image = null; + } + if (image == null) + { + file = await NetHelper.DownloadImageAsync(url, id, working, folder); + if (file != null) + { + image = ImageSource.FromFile(file); + } + } + return image; + } } public static class Config @@ -20,7 +82,14 @@ namespace Gallery.Util public const string UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36"; public const string AcceptLanguage = "zh-cn"; + public const string AcceptImage = "image/png,image/*,*/*;q=0.8"; public static WebProxy Proxy; } + + public static class Routes + { + public const string Gallery = "gallery"; + public const string Option = "option"; + } } diff --git a/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Contents.json b/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Contents.json index fc9d330..21fed74 100644 --- a/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,117 +1,116 @@ -{ +{ "images": [ { - "scale": "2x", + "filename": "Icon40.png", "size": "20x20", - "idiom": "iphone", - "filename": "Icon40.png" + "scale": "2x", + "idiom": "iphone" }, { - "scale": "3x", + "filename": "Icon60.png", "size": "20x20", - "idiom": "iphone", - "filename": "Icon60.png" - }, - { - "scale": "2x", - "size": "29x29", - "idiom": "iphone", - "filename": "Icon58.png" - }, - { "scale": "3x", + "idiom": "iphone" + }, + { + "filename": "Icon58.png", "size": "29x29", - "idiom": "iphone", - "filename": "Icon87.png" - }, - { "scale": "2x", - "size": "40x40", - "idiom": "iphone", - "filename": "Icon80.png" + "idiom": "iphone" }, { + "filename": "Icon87.png", + "size": "29x29", "scale": "3x", - "size": "40x40", - "idiom": "iphone", - "filename": "Icon120.png" + "idiom": "iphone" }, { + "filename": "Icon80.png", + "size": "40x40", "scale": "2x", + "idiom": "iphone" + }, + { + "filename": "Icon120.png", + "size": "40x40", + "scale": "3x", + "idiom": "iphone" + }, + { + "filename": "Icon120.png", "size": "60x60", - "idiom": "iphone", - "filename": "Icon120.png" + "scale": "2x", + "idiom": "iphone" }, { - "scale": "3x", + "filename": "Icon180.png", "size": "60x60", - "idiom": "iphone", - "filename": "Icon180.png" + "scale": "3x", + "idiom": "iphone" }, { - "scale": "1x", + "filename": "Icon20.png", "size": "20x20", - "idiom": "ipad", - "filename": "Icon20.png" + "scale": "1x", + "idiom": "ipad" }, { - "scale": "2x", + "filename": "Icon40.png", "size": "20x20", - "idiom": "ipad", - "filename": "Icon40.png" + "scale": "2x", + "idiom": "ipad" }, { - "scale": "1x", + "filename": "Icon29.png", "size": "29x29", - "idiom": "ipad", - "filename": "Icon29.png" + "scale": "1x", + "idiom": "ipad" }, { - "scale": "2x", + "filename": "Icon58.png", "size": "29x29", - "idiom": "ipad", - "filename": "Icon58.png" + "scale": "2x", + "idiom": "ipad" }, { - "scale": "1x", + "filename": "Icon40.png", "size": "40x40", - "idiom": "ipad", - "filename": "Icon40.png" - }, - { - "scale": "2x", - "size": "40x40", - "idiom": "ipad", - "filename": "Icon80.png" - }, - { "scale": "1x", - "size": "76x76", - "idiom": "ipad", - "filename": "Icon76.png" + "idiom": "ipad" }, { + "filename": "Icon80.png", + "size": "40x40", "scale": "2x", - "size": "76x76", - "idiom": "ipad", - "filename": "Icon152.png" + "idiom": "ipad" }, { - "scale": "2x", + "filename": "Icon167.png", "size": "83.5x83.5", - "idiom": "ipad", - "filename": "Icon167.png" + "scale": "2x", + "idiom": "ipad" }, { + "filename": "Icon76.png", + "size": "76x76", "scale": "1x", + "idiom": "ipad" + }, + { + "filename": "Icon152.png", + "size": "76x76", + "scale": "2x", + "idiom": "ipad" + }, + { + "filename": "Icon1024.png", "size": "1024x1024", - "idiom": "ios-marketing", - "filename": "Icon1024.png" + "scale": "1x", + "idiom": "ios-marketing" } ], - "properties": {}, "info": { "version": 1, "author": "xcode" } -} +} \ No newline at end of file diff --git a/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Icon1024.png b/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Icon1024.png index 9174c98..88e5af0 100644 Binary files a/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Icon1024.png and b/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Icon1024.png differ diff --git a/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Icon120.png b/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Icon120.png index 9c60a17..523d3e1 100644 Binary files a/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Icon120.png and b/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Icon120.png differ diff --git a/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Icon152.png b/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Icon152.png index 448d6ef..61cefe5 100644 Binary files a/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Icon152.png and b/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Icon152.png differ diff --git a/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Icon167.png b/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Icon167.png index 8524768..a298163 100644 Binary files a/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Icon167.png and b/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Icon167.png differ diff --git a/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Icon180.png b/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Icon180.png index 60a6470..71662ea 100644 Binary files a/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Icon180.png and b/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Icon180.png differ diff --git a/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Icon20.png b/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Icon20.png index 45268a6..c27f0ef 100644 Binary files a/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Icon20.png and b/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Icon20.png differ diff --git a/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Icon29.png b/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Icon29.png index 6a6c77a..5c08a4a 100644 Binary files a/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Icon29.png and b/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Icon29.png differ diff --git a/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Icon40.png b/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Icon40.png index cc7edcf..86bba9e 100644 Binary files a/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Icon40.png and b/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Icon40.png differ diff --git a/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Icon58.png b/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Icon58.png index 1ad04f0..c4713e3 100644 Binary files a/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Icon58.png and b/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Icon58.png differ diff --git a/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Icon60.png b/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Icon60.png index 2dd5262..76ca617 100644 Binary files a/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Icon60.png and b/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Icon60.png differ diff --git a/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Icon76.png b/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Icon76.png index b058cae..a254624 100644 Binary files a/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Icon76.png and b/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Icon76.png differ diff --git a/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Icon80.png b/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Icon80.png index 02e47a2..f26da18 100644 Binary files a/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Icon80.png and b/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Icon80.png differ diff --git a/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Icon87.png b/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Icon87.png index 4954a4b..6599f44 100644 Binary files a/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Icon87.png and b/Gallery.iOS/Assets.xcassets/AppIcon.appiconset/Icon87.png differ diff --git a/Gallery.iOS/Assets.xcassets/IconSource.imageset/Contents.json b/Gallery.iOS/Assets.xcassets/IconSource.imageset/Contents.json new file mode 100644 index 0000000..90bdcd4 --- /dev/null +++ b/Gallery.iOS/Assets.xcassets/IconSource.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images": [ + { + "idiom": "universal" + }, + { + "filename": "source-solid.png", + "scale": "1x", + "idiom": "universal" + }, + { + "filename": "source-solid@2x.png", + "scale": "2x", + "idiom": "universal" + }, + { + "filename": "source-solid@3x.png", + "scale": "3x", + "idiom": "universal" + } + ], + "info": { + "version": 1, + "author": "xcode" + } +} \ No newline at end of file diff --git a/Gallery.iOS/Assets.xcassets/IconSource.imageset/source-solid.png b/Gallery.iOS/Assets.xcassets/IconSource.imageset/source-solid.png new file mode 100644 index 0000000..4e01627 Binary files /dev/null and b/Gallery.iOS/Assets.xcassets/IconSource.imageset/source-solid.png differ diff --git a/Gallery.iOS/Assets.xcassets/IconSource.imageset/source-solid@2x.png b/Gallery.iOS/Assets.xcassets/IconSource.imageset/source-solid@2x.png new file mode 100644 index 0000000..e94fd60 Binary files /dev/null and b/Gallery.iOS/Assets.xcassets/IconSource.imageset/source-solid@2x.png differ diff --git a/Gallery.iOS/Assets.xcassets/IconSource.imageset/source-solid@3x.png b/Gallery.iOS/Assets.xcassets/IconSource.imageset/source-solid@3x.png new file mode 100644 index 0000000..ba2ab7c Binary files /dev/null and b/Gallery.iOS/Assets.xcassets/IconSource.imageset/source-solid@3x.png differ diff --git a/Gallery.iOS/Assets.xcassets/IconSourceRegular.imageset/Contents.json b/Gallery.iOS/Assets.xcassets/IconSourceRegular.imageset/Contents.json new file mode 100644 index 0000000..903ab49 --- /dev/null +++ b/Gallery.iOS/Assets.xcassets/IconSourceRegular.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images": [ + { + "idiom": "universal" + }, + { + "filename": "source-regular.png", + "scale": "1x", + "idiom": "universal" + }, + { + "filename": "source-regular@2x.png", + "scale": "2x", + "idiom": "universal" + }, + { + "filename": "source-regular@3x.png", + "scale": "3x", + "idiom": "universal" + } + ], + "info": { + "version": 1, + "author": "xcode" + } +} \ No newline at end of file diff --git a/Gallery.iOS/Assets.xcassets/IconSourceRegular.imageset/source-regular.png b/Gallery.iOS/Assets.xcassets/IconSourceRegular.imageset/source-regular.png new file mode 100644 index 0000000..9da6fa8 Binary files /dev/null and b/Gallery.iOS/Assets.xcassets/IconSourceRegular.imageset/source-regular.png differ diff --git a/Gallery.iOS/Assets.xcassets/IconSourceRegular.imageset/source-regular@2x.png b/Gallery.iOS/Assets.xcassets/IconSourceRegular.imageset/source-regular@2x.png new file mode 100644 index 0000000..ef89402 Binary files /dev/null and b/Gallery.iOS/Assets.xcassets/IconSourceRegular.imageset/source-regular@2x.png differ diff --git a/Gallery.iOS/Assets.xcassets/IconSourceRegular.imageset/source-regular@3x.png b/Gallery.iOS/Assets.xcassets/IconSourceRegular.imageset/source-regular@3x.png new file mode 100644 index 0000000..6cd26c7 Binary files /dev/null and b/Gallery.iOS/Assets.xcassets/IconSourceRegular.imageset/source-regular@3x.png differ diff --git a/Gallery.iOS/Assets.xcassets/IconYandere.imageset/Contents.json b/Gallery.iOS/Assets.xcassets/IconYandere.imageset/Contents.json new file mode 100644 index 0000000..763e342 --- /dev/null +++ b/Gallery.iOS/Assets.xcassets/IconYandere.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images": [ + { + "idiom": "universal" + }, + { + "filename": "yandere-solid.png", + "scale": "1x", + "idiom": "universal" + }, + { + "filename": "yandere-solid@2x.png", + "scale": "2x", + "idiom": "universal" + }, + { + "filename": "yandere-solid@3x.png", + "scale": "3x", + "idiom": "universal" + } + ], + "info": { + "version": 1, + "author": "xcode" + } +} \ No newline at end of file diff --git a/Gallery.iOS/Assets.xcassets/IconYandere.imageset/yandere-solid.png b/Gallery.iOS/Assets.xcassets/IconYandere.imageset/yandere-solid.png new file mode 100644 index 0000000..7d5af4b Binary files /dev/null and b/Gallery.iOS/Assets.xcassets/IconYandere.imageset/yandere-solid.png differ diff --git a/Gallery.iOS/Assets.xcassets/IconYandere.imageset/yandere-solid@2x.png b/Gallery.iOS/Assets.xcassets/IconYandere.imageset/yandere-solid@2x.png new file mode 100644 index 0000000..a6ea902 Binary files /dev/null and b/Gallery.iOS/Assets.xcassets/IconYandere.imageset/yandere-solid@2x.png differ diff --git a/Gallery.iOS/Assets.xcassets/IconYandere.imageset/yandere-solid@3x.png b/Gallery.iOS/Assets.xcassets/IconYandere.imageset/yandere-solid@3x.png new file mode 100644 index 0000000..af788b1 Binary files /dev/null and b/Gallery.iOS/Assets.xcassets/IconYandere.imageset/yandere-solid@3x.png differ diff --git a/Gallery.iOS/Assets.xcassets/IconYandereRegular.imageset/Contents.json b/Gallery.iOS/Assets.xcassets/IconYandereRegular.imageset/Contents.json new file mode 100644 index 0000000..0dc7095 --- /dev/null +++ b/Gallery.iOS/Assets.xcassets/IconYandereRegular.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images": [ + { + "idiom": "universal" + }, + { + "filename": "yandere-regular.png", + "scale": "1x", + "idiom": "universal" + }, + { + "filename": "yandere-regular@2x.png", + "scale": "2x", + "idiom": "universal" + }, + { + "filename": "yandere-regular@3x.png", + "scale": "3x", + "idiom": "universal" + } + ], + "info": { + "version": 1, + "author": "xcode" + } +} \ No newline at end of file diff --git a/Gallery.iOS/Assets.xcassets/IconYandereRegular.imageset/yandere-regular.png b/Gallery.iOS/Assets.xcassets/IconYandereRegular.imageset/yandere-regular.png new file mode 100644 index 0000000..03f1a85 Binary files /dev/null and b/Gallery.iOS/Assets.xcassets/IconYandereRegular.imageset/yandere-regular.png differ diff --git a/Gallery.iOS/Assets.xcassets/IconYandereRegular.imageset/yandere-regular@2x.png b/Gallery.iOS/Assets.xcassets/IconYandereRegular.imageset/yandere-regular@2x.png new file mode 100644 index 0000000..6491ebb Binary files /dev/null and b/Gallery.iOS/Assets.xcassets/IconYandereRegular.imageset/yandere-regular@2x.png differ diff --git a/Gallery.iOS/Assets.xcassets/IconYandereRegular.imageset/yandere-regular@3x.png b/Gallery.iOS/Assets.xcassets/IconYandereRegular.imageset/yandere-regular@3x.png new file mode 100644 index 0000000..d730c47 Binary files /dev/null and b/Gallery.iOS/Assets.xcassets/IconYandereRegular.imageset/yandere-regular@3x.png differ diff --git a/Gallery.iOS/Gallery.iOS.csproj b/Gallery.iOS/Gallery.iOS.csproj index 9d62482..4b6a46b 100644 --- a/Gallery.iOS/Gallery.iOS.csproj +++ b/Gallery.iOS/Gallery.iOS.csproj @@ -80,63 +80,58 @@ + + + + + + + + + + + false - - false - - - false - - - false - - - false - - - false - - - false - - - false - - - false - - - false - - - false - - - false - - - false - - - false - - - - - - - + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + @@ -168,6 +163,15 @@ Gallery.Danbooru + + + + + + + + + \ No newline at end of file diff --git a/Gallery.iOS/Renderers/AppShellRenderer.cs b/Gallery.iOS/Renderers/AppShellRenderer.cs new file mode 100644 index 0000000..2415d4c --- /dev/null +++ b/Gallery.iOS/Renderers/AppShellRenderer.cs @@ -0,0 +1,93 @@ +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using Gallery.iOS.Renderers; +using Gallery.iOS.Renderers.AppShellSection; +using Gallery.Services; +using UIKit; +using Xamarin.Forms; +using Xamarin.Forms.Platform.iOS; + +[assembly: ExportRenderer(typeof(Shell), typeof(AppShellRenderer))] +namespace Gallery.iOS.Renderers +{ + public class AppShellRenderer : ShellRenderer + { + + public override bool PrefersHomeIndicatorAutoHidden => Screen.GetHomeIndicatorAutoHidden(Element); + + protected override IShellSectionRenderer CreateShellSectionRenderer(ShellSection shellSection) + { + var renderer = base.CreateShellSectionRenderer(shellSection); // new AppShellSectionRenderer(this); + if (renderer is ShellSectionRenderer sr && Element is AppShell shell) + { + shell.SetNavigationBarHeight(sr.NavigationBar.Frame.Height); + shell.SetStatusBarHeight( + sr.NavigationBar.Frame.Height, + UIApplication.SharedApplication.StatusBarFrame.Height); + } + return renderer; + } + + protected override IShellItemTransition CreateShellItemTransition() + { + return new AppShellItemTransition(); + } + + protected override IShellTabBarAppearanceTracker CreateTabBarAppearanceTracker() + { + return new AppShellTabBarAppearanceTracker(); + } + + protected override IShellNavBarAppearanceTracker CreateNavBarAppearanceTracker() + { + return new AppShellNavBarAppearanceTracker(); + } + + protected override void UpdateBackgroundColor() + { + NativeView.BackgroundColor = Color.Transparent.ToUIColor(); + } + } + + public class AppShellItemTransition : IShellItemTransition + { + [SuppressMessage("Code Notifications", "XI0001:Notifies you with advices on how to use Apple APIs", Justification = "")] + public Task Transition(IShellItemRenderer oldRenderer, IShellItemRenderer newRenderer) + { + var task = new TaskCompletionSource(); + var oldView = oldRenderer.ViewController.View; + var newView = newRenderer.ViewController.View; + newView.Alpha = 0; + + newView.Superview.InsertSubviewAbove(newView, oldView); + + UIView.Animate(0.2, 0, UIViewAnimationOptions.BeginFromCurrentState, () => newView.Alpha = 1, () => task.TrySetResult(true)); + + return task.Task; + } + } + + public class AppShellSectionRenderer : ShellSectionRenderer + { + public AppShellSectionRenderer(IShellContext context) : base(context) + { + } + + protected override IShellSectionRootRenderer CreateShellSectionRootRenderer(ShellSection shellSection, IShellContext shellContext) + { + return new AppShellSectionRootRenderer(shellSection, shellContext); + } + } + + public class AppShellSectionRootRenderer : ShellSectionRootRenderer + { + public AppShellSectionRootRenderer(ShellSection shellSection, IShellContext shellContext) : base(shellSection, shellContext) + { + } + + protected override IShellSectionRootHeader CreateShellSectionRootHeader(IShellContext shellContext) + { + return new AppShellSectionRootHeader(shellContext); + } + } +} diff --git a/Gallery.iOS/Renderers/AppShellSection/AppAppearanceTracker.cs b/Gallery.iOS/Renderers/AppShellSection/AppAppearanceTracker.cs new file mode 100644 index 0000000..0bcebbd --- /dev/null +++ b/Gallery.iOS/Renderers/AppShellSection/AppAppearanceTracker.cs @@ -0,0 +1,168 @@ +using System.Diagnostics.CodeAnalysis; +using CoreGraphics; +using UIKit; +using Xamarin.Forms; +using Xamarin.Forms.Platform.iOS; + +namespace Gallery.iOS.Renderers.AppShellSection +{ + public class AppShellNavBarAppearanceTracker : IShellNavBarAppearanceTracker + { + UIColor _defaultBarTint; + UIColor _defaultTint; + UIStringAttributes _defaultTitleAttributes; + float _shadowOpacity = float.MinValue; + CGColor _shadowColor; + + public void ResetAppearance(UINavigationController controller) + { + if (_defaultTint != null) + { + var navBar = controller.NavigationBar; + navBar.TintColor = _defaultBarTint; + navBar.TintColor = _defaultTint; + navBar.TitleTextAttributes = _defaultTitleAttributes; + } + } + + public void SetAppearance(UINavigationController controller, ShellAppearance appearance) + { + var background = appearance.BackgroundColor; + var foreground = appearance.ForegroundColor; + var titleColor = appearance.TitleColor; + + var navBar = controller.NavigationBar; + + if (_defaultTint == null) + { + _defaultBarTint = navBar.BarTintColor; + _defaultTint = navBar.TintColor; + _defaultTitleAttributes = navBar.TitleTextAttributes; + } + + if (UIDevice.CurrentDevice.CheckSystemVersion(13, 0)) + { + navBar.TintColor = UIColor.SecondaryLabelColor; + } + else + { + if (!background.IsDefault) + navBar.BarTintColor = background.ToUIColor(); + if (!foreground.IsDefault) + navBar.TintColor = foreground.ToUIColor(); + if (!titleColor.IsDefault) + { + navBar.TitleTextAttributes = new UIStringAttributes + { + ForegroundColor = titleColor.ToUIColor() + }; + } + } + } + + public void SetHasShadow(UINavigationController controller, bool hasShadow) + { + var navigationBar = controller.NavigationBar; + if (_shadowOpacity == float.MinValue) + { + // Don't do anything if user hasn't changed the shadow to true + if (!hasShadow) + return; + + _shadowOpacity = navigationBar.Layer.ShadowOpacity; + _shadowColor = navigationBar.Layer.ShadowColor; + } + + if (hasShadow) + { + navigationBar.Layer.ShadowColor = UIColor.Black.CGColor; + navigationBar.Layer.ShadowOpacity = 1.0f; + } + else + { + navigationBar.Layer.ShadowColor = _shadowColor; + navigationBar.Layer.ShadowOpacity = _shadowOpacity; + } + } + + public void Dispose() + { + } + + public void UpdateLayout(UINavigationController controller) + { + } + } + + public class AppShellTabBarAppearanceTracker : IShellTabBarAppearanceTracker + { + UIColor _defaultBarTint; + UIColor _defaultTint; + UIColor _defaultUnselectedTint; + + public void ResetAppearance(UITabBarController controller) + { + if (_defaultTint == null) + return; + + var tabBar = controller.TabBar; + tabBar.BarTintColor = _defaultBarTint; + tabBar.TintColor = _defaultTint; + tabBar.UnselectedItemTintColor = _defaultUnselectedTint; + } + + public void SetAppearance(UITabBarController controller, ShellAppearance appearance) + { + IShellAppearanceElement appearanceElement = appearance; + var backgroundColor = appearanceElement.EffectiveTabBarBackgroundColor; + var unselectedColor = appearanceElement.EffectiveTabBarUnselectedColor; + var tintColor = appearanceElement.EffectiveTabBarForegroundColor; // appearanceElement.EffectiveTabBarTitleColor; + + var tabBar = controller.TabBar; + + if (_defaultTint == null) + { + _defaultBarTint = tabBar.BarTintColor; + _defaultTint = tabBar.TintColor; + _defaultUnselectedTint = tabBar.UnselectedItemTintColor; + } + + if (UIDevice.CurrentDevice.CheckSystemVersion(13, 0)) + { + tabBar.TintColor = UIColor.LabelColor; + //tabBar.UnselectedItemTintColor = UIColor.TertiaryLabelColor; + } + else + { + if (!backgroundColor.IsDefault) + tabBar.BarTintColor = backgroundColor.ToUIColor(); + if (!tintColor.IsDefault) + tabBar.TintColor = tintColor.ToUIColor(); + if (!unselectedColor.IsDefault) + tabBar.UnselectedItemTintColor = unselectedColor.ToUIColor(); + } + } + + public void Dispose() + { + } + + [SuppressMessage("Code Notifications", "XI0001:Notifies you with advices on how to use Apple APIs", Justification = "")] + public void UpdateLayout(UITabBarController controller) + { + var tabBar = controller.TabBar; + if (tabBar != null && tabBar.Items != null && tabBar.Items.Length == 3) + { + var tabBarItem = tabBar.Items[0]; + tabBarItem.Image = UIImage.FromBundle("IconYandereRegular"); + tabBarItem.SelectedImage = UIImage.FromBundle("IconYandere"); + tabBarItem = tabBar.Items[1]; + tabBarItem.Image = UIImage.FromBundle("IconSourceRegular"); + tabBarItem.SelectedImage = UIImage.FromBundle("IconSource"); + tabBarItem = tabBar.Items[2]; + tabBarItem.Image = UIImage.FromBundle("IconSourceRegular"); + tabBarItem.SelectedImage = UIImage.FromBundle("IconSource"); + } + } + } +} diff --git a/Gallery.iOS/Renderers/AppShellSection/AppShellSectionRootHeader.cs b/Gallery.iOS/Renderers/AppShellSection/AppShellSectionRootHeader.cs new file mode 100644 index 0000000..6f17704 --- /dev/null +++ b/Gallery.iOS/Renderers/AppShellSection/AppShellSectionRootHeader.cs @@ -0,0 +1,325 @@ +using System; +using System.Collections.Specialized; +using System.ComponentModel; +using CoreGraphics; +using Foundation; +using UIKit; +using Xamarin.Forms; +using Xamarin.Forms.Platform.iOS; + +namespace Gallery.iOS.Renderers.AppShellSection +{ + public class AppShellSectionRootHeader : UICollectionViewController, IAppearanceObserver, IShellSectionRootHeader + { + #region IAppearanceObserver + + readonly Color _defaultBackgroundColor = new(0.964); + readonly Color _defaultForegroundColor = Color.Black; + readonly Color _defaultUnselectedColor = Color.Black.MultiplyAlpha(0.7); + + void IAppearanceObserver.OnAppearanceChanged(ShellAppearance appearance) + { + if (appearance == null) + ResetAppearance(); + else + SetAppearance(appearance); + } + + protected virtual void ResetAppearance() + { + SetValues(_defaultBackgroundColor, _defaultForegroundColor, _defaultUnselectedColor); + } + + protected virtual void SetAppearance(ShellAppearance appearance) + { + SetValues(appearance.BackgroundColor.IsDefault ? _defaultBackgroundColor : appearance.BackgroundColor, + appearance.ForegroundColor.IsDefault ? _defaultForegroundColor : appearance.ForegroundColor, + appearance.UnselectedColor.IsDefault ? _defaultUnselectedColor : appearance.UnselectedColor); + } + + void SetValues(Color backgroundColor, Color foregroundColor, Color unselectedColor) + { + CollectionView.BackgroundColor = new Color(backgroundColor.R, backgroundColor.G, backgroundColor.B, .663).ToUIColor(); + _bar.BackgroundColor = foregroundColor.ToUIColor(); + + bool reloadData = _selectedColor != foregroundColor || _unselectedColor != unselectedColor; + + _selectedColor = foregroundColor; + _unselectedColor = unselectedColor; + + if (reloadData) + CollectionView.ReloadData(); + } + + #endregion IAppearanceObserver + + static readonly NSString CellId = new("HeaderCell"); + + readonly IShellContext _shellContext; + UIView _bar; + UIView _bottomShadow; + Color _selectedColor; + Color _unselectedColor; + bool _isDisposed; + + public AppShellSectionRootHeader(IShellContext shellContext) : base(new UICollectionViewFlowLayout()) + { + _shellContext = shellContext; + } + + public double SelectedIndex { get; set; } + public ShellSection ShellSection { get; set; } + IShellSectionController ShellSectionController => ShellSection; + + public UIViewController ViewController => this; + + public override bool CanMoveItem(UICollectionView collectionView, NSIndexPath indexPath) + { + return false; + } + + public override UICollectionViewCell GetCell(UICollectionView collectionView, NSIndexPath indexPath) + { + var reusedCell = (UICollectionViewCell)collectionView.DequeueReusableCell(CellId, indexPath); + + if (reusedCell is not ShellSectionHeaderCell headerCell) + return reusedCell; + + var selectedItems = collectionView.GetIndexPathsForSelectedItems(); + + var shellContent = ShellSectionController.GetItems()[indexPath.Row]; + headerCell.Label.Text = shellContent.Title; + headerCell.Label.SetNeedsDisplay(); + + headerCell.SelectedColor = _selectedColor.ToUIColor(); + headerCell.UnSelectedColor = _unselectedColor.ToUIColor(); + + if (selectedItems.Length > 0 && selectedItems[0].Row == indexPath.Row) + headerCell.Selected = true; + else + headerCell.Selected = false; + + return headerCell; + } + + public override nint GetItemsCount(UICollectionView collectionView, nint section) + { + return ShellSectionController.GetItems().Count; + } + + public override void ItemDeselected(UICollectionView collectionView, NSIndexPath indexPath) + { + if (CollectionView.CellForItem(indexPath) is ShellSectionHeaderCell cell) + cell.Label.TextColor = _unselectedColor.ToUIColor(); + } + + public override void ItemSelected(UICollectionView collectionView, NSIndexPath indexPath) + { + var row = indexPath.Row; + + var item = ShellSectionController.GetItems()[row]; + + if (item != ShellSection.CurrentItem) + ShellSection.SetValueFromRenderer(ShellSection.CurrentItemProperty, item); + + if (CollectionView.CellForItem(indexPath) is ShellSectionHeaderCell cell) + cell.Label.TextColor = _selectedColor.ToUIColor(); + } + + public override nint NumberOfSections(UICollectionView collectionView) + { + return 1; + } + + public override bool ShouldSelectItem(UICollectionView collectionView, NSIndexPath indexPath) + { + var row = indexPath.Row; + var item = ShellSectionController.GetItems()[row]; + IShellController shellController = _shellContext.Shell; + + if (item == ShellSection.CurrentItem) + return true; + return shellController.ProposeNavigation(ShellNavigationSource.ShellContentChanged, (ShellItem)ShellSection.Parent, ShellSection, item, ShellSection.Stack, true); + } + + public override void ViewDidLayoutSubviews() + { + if (_isDisposed) + return; + + base.ViewDidLayoutSubviews(); + + LayoutBar(); + + _bottomShadow.Frame = new CGRect(0, CollectionView.Frame.Bottom, CollectionView.Frame.Width, 0.5); + } + + public override void ViewDidLoad() + { + if (_isDisposed) + return; + + base.ViewDidLoad(); + + CollectionView.ScrollsToTop = false; + CollectionView.Bounces = false; + CollectionView.AlwaysBounceHorizontal = false; + CollectionView.ShowsHorizontalScrollIndicator = false; + CollectionView.ClipsToBounds = false; + + _bar = new UIView(new CGRect(0, 0, 20, 20)); + _bar.Layer.ZPosition = 9001; //its over 9000! + CollectionView.AddSubview(_bar); + + _bottomShadow = new UIView(new CGRect(0, 0, 10, 1)) + { + BackgroundColor = Color.Black.MultiplyAlpha(0.3).ToUIColor() + }; + _bottomShadow.Layer.ZPosition = 9002; + CollectionView.AddSubview(_bottomShadow); + + var flowLayout = Layout as UICollectionViewFlowLayout; + flowLayout.ScrollDirection = UICollectionViewScrollDirection.Horizontal; + flowLayout.MinimumInteritemSpacing = 0; + flowLayout.MinimumLineSpacing = 0; + flowLayout.EstimatedItemSize = new CGSize(70, 35); + + CollectionView.RegisterClassForCell(GetCellType(), CellId); + + ((IShellController)_shellContext.Shell).AddAppearanceObserver(this, ShellSection); + ShellSectionController.ItemsCollectionChanged += OnShellSectionItemsChanged; + + UpdateSelectedIndex(); + ShellSection.PropertyChanged += OnShellSectionPropertyChanged; + } + + protected virtual Type GetCellType() + { + return typeof(ShellSectionHeaderCell); + } + + protected override void Dispose(bool disposing) + { + if (_isDisposed) + return; + + if (disposing) + { + ((IShellController)_shellContext.Shell).RemoveAppearanceObserver(this); + ShellSectionController.ItemsCollectionChanged -= OnShellSectionItemsChanged; + ShellSection.PropertyChanged -= OnShellSectionPropertyChanged; + + ShellSection = null; + _bar.RemoveFromSuperview(); + RemoveFromParentViewController(); + _bar.Dispose(); + _bar = null; + } + + _isDisposed = true; + base.Dispose(disposing); + } + + protected void LayoutBar() + { + if (SelectedIndex < 0) + return; + + if (ShellSectionController.GetItems().IndexOf(ShellSection.CurrentItem) != SelectedIndex) + return; + + var layout = CollectionView.GetLayoutAttributesForItem(NSIndexPath.FromItemSection((int)SelectedIndex, 0)); + + if (layout == null) + return; + + var frame = layout.Frame; + + if (_bar.Frame.Height != 2) + { + _bar.Frame = new CGRect(frame.X, frame.Bottom - 2, frame.Width, 2); + } + else + { + UIView.Animate(.25, () => _bar.Frame = new CGRect(frame.X, frame.Bottom - 2, frame.Width, 2)); + } + } + + protected virtual void OnShellSectionPropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == ShellSection.CurrentItemProperty.PropertyName) + { + UpdateSelectedIndex(); + } + } + + protected virtual void UpdateSelectedIndex(bool animated = false) + { + if (ShellSection.CurrentItem == null) + return; + + SelectedIndex = ShellSectionController.GetItems().IndexOf(ShellSection.CurrentItem); + + if (SelectedIndex < 0) + return; + + LayoutBar(); + + CollectionView.SelectItem(NSIndexPath.FromItemSection((int)SelectedIndex, 0), false, UICollectionViewScrollPosition.CenteredHorizontally); + } + + void OnShellSectionItemsChanged(object sender, NotifyCollectionChangedEventArgs e) + { + if (_isDisposed) + return; + + CollectionView.ReloadData(); + } + + public class ShellSectionHeaderCell : UICollectionViewCell + { + public UIColor SelectedColor { get; set; } + public UIColor UnSelectedColor { get; set; } + + public ShellSectionHeaderCell() + { + + } + + [Export("initWithFrame:")] + public ShellSectionHeaderCell(CGRect frame) : base(frame) + { + Label = new UILabel + { + TextAlignment = UITextAlignment.Center, + Font = UIFont.BoldSystemFontOfSize(14) + }; + ContentView.AddSubview(Label); + } + + public override bool Selected + { + get => base.Selected; + set + { + base.Selected = value; + Label.TextColor = value ? SelectedColor : UnSelectedColor; + } + } + + public UILabel Label { get; } + + public override void LayoutSubviews() + { + base.LayoutSubviews(); + + Label.Frame = Bounds; + } + + public override CGSize SizeThatFits(CGSize size) + { + return new CGSize(Label.SizeThatFits(size).Width + 30, 35); + } + } + } +} diff --git a/Gallery.iOS/Renderers/BlurryPanelRenderer.cs b/Gallery.iOS/Renderers/BlurryPanelRenderer.cs new file mode 100644 index 0000000..55ded8d --- /dev/null +++ b/Gallery.iOS/Renderers/BlurryPanelRenderer.cs @@ -0,0 +1,87 @@ +using CoreAnimation; +using Gallery.iOS.Renderers; +using Gallery.Resources.UI; +using UIKit; +using Xamarin.Forms; +using Xamarin.Forms.Platform.iOS; + +[assembly: ExportRenderer(typeof(BlurryPanel), typeof(BlurryPanelRenderer))] +namespace Gallery.iOS.Renderers +{ + public class BlurryPanelRenderer : ViewRenderer + { + private UIVisualEffectView nativeControl; + private CALayer bottom; + + protected override void OnElementChanged(ElementChangedEventArgs e) + { + base.OnElementChanged(e); + + if (e.OldElement != null) + { + if (bottom != null) + { + if (bottom.SuperLayer != null) + { + bottom.RemoveFromSuperLayer(); + } + bottom.Dispose(); + bottom = null; + } + } + + if (e.NewElement != null) + { + e.NewElement.BackgroundColor = Color.Default; + if (Control == null) + { + var blur = UIBlurEffect.FromStyle(UIBlurEffectStyle.SystemMaterial); + nativeControl = new UIVisualEffectView(blur) + { + Frame = Frame + }; + SetNativeControl(nativeControl); + } + } + } + + public override void LayoutSubviews() + { + base.LayoutSubviews(); + + if (nativeControl != null) + { + if (bottom == null) + { + bottom = new CALayer + { + BackgroundColor = UIColor.White.CGColor, + ShadowColor = UIColor.Black.CGColor, + ShadowOpacity = 1.0f + }; + } + if (bottom.SuperLayer == null) + { + nativeControl.Layer.InsertSublayer(bottom, 0); + } + bottom.Frame = new CoreGraphics.CGRect(0, Frame.Height - 5, Frame.Width, 5); + nativeControl.Frame = Frame; + } + } + + protected override void Dispose(bool disposing) + { + if (bottom != null) + { + if (bottom.SuperLayer != null) + { + bottom.RemoveFromSuperLayer(); + } + bottom.Dispose(); + bottom = null; + } + + base.Dispose(disposing); + } + } +} diff --git a/Gallery.iOS/Renderers/CardViewRenderer.cs b/Gallery.iOS/Renderers/CardViewRenderer.cs new file mode 100644 index 0000000..4de4aaa --- /dev/null +++ b/Gallery.iOS/Renderers/CardViewRenderer.cs @@ -0,0 +1,51 @@ +using Gallery.iOS.Renderers; +using Gallery.Resources.UI; +using Xamarin.Forms; +using Xamarin.Forms.Platform.iOS; + +[assembly: ExportRenderer(typeof(CardView), typeof(CardViewRenderer))] +namespace Gallery.iOS.Renderers +{ + public class CardViewRenderer : VisualElementRenderer + { + protected override void OnElementChanged(ElementChangedEventArgs e) + { + base.OnElementChanged(e); + + var layer = Layer; + var element = e.NewElement; + if (layer != null && element != null) + { + var cornerRadius = element.CornerRadius; + if (cornerRadius > 0) + { + layer.CornerRadius = cornerRadius; + } + + //if (element.BackgroundColor != default) + //{ + // layer.BackgroundColor = element.BackgroundColor.ToCGColor(); + //} + + var shadowColor = element.ShadowColor; + if (shadowColor != default) + { + layer.ShadowColor = shadowColor.ToCGColor(); + layer.ShadowOpacity = 1f; + + var radius = element.ShadowRadius; + if (radius > 0) + { + layer.ShadowRadius = radius; + } + + layer.ShadowOffset = element.ShadowOffset.ToSizeF(); + } + else + { + layer.ShadowOpacity = 0f; + } + } + } + } +} diff --git a/Gallery.iOS/Renderers/CircleImageRenderer.cs b/Gallery.iOS/Renderers/CircleImageRenderer.cs new file mode 100644 index 0000000..0afb1cf --- /dev/null +++ b/Gallery.iOS/Renderers/CircleImageRenderer.cs @@ -0,0 +1,33 @@ +using Gallery.iOS.Renderers; +using Gallery.Resources.UI; +using Xamarin.Forms; +using Xamarin.Forms.Platform.iOS; + +[assembly: ExportRenderer(typeof(CircleImage), typeof(CircleImageRenderer))] +namespace Gallery.iOS.Renderers +{ + public class CircleImageRenderer : ImageRenderer + { + protected override void OnElementChanged(ElementChangedEventArgs e) + { + base.OnElementChanged(e); + + var layer = Layer; + if (layer != null) + { + layer.MasksToBounds = true; + } + } + + public override void LayoutSubviews() + { + base.LayoutSubviews(); + + var control = Control; + if (control != null) + { + control.Layer.CornerRadius = control.Frame.Size.Width / 2; + } + } + } +} diff --git a/Gallery.iOS/Renderers/OptionEntryRenderer.cs b/Gallery.iOS/Renderers/OptionEntryRenderer.cs new file mode 100644 index 0000000..a61809f --- /dev/null +++ b/Gallery.iOS/Renderers/OptionEntryRenderer.cs @@ -0,0 +1,22 @@ +using Gallery.iOS.Renderers; +using Gallery.Resources.UI; +using Xamarin.Forms; +using Xamarin.Forms.Platform.iOS; + +[assembly: ExportRenderer(typeof(OptionEntry), typeof(OptionEntryRenderer))] +namespace Gallery.iOS.Renderers +{ + public class OptionEntryRenderer : EntryRenderer + { + protected override void OnElementChanged(ElementChangedEventArgs e) + { + base.OnElementChanged(e); + + var control = Control; + if (control != null) + { + control.BorderStyle = UIKit.UITextBorderStyle.None; + } + } + } +} diff --git a/Gallery.iOS/Renderers/OptionPickerRenderer.cs b/Gallery.iOS/Renderers/OptionPickerRenderer.cs new file mode 100644 index 0000000..882e5e0 --- /dev/null +++ b/Gallery.iOS/Renderers/OptionPickerRenderer.cs @@ -0,0 +1,22 @@ +using Gallery.iOS.Renderers; +using Xamarin.Forms; +using Xamarin.Forms.Platform.iOS; + +[assembly: ExportRenderer(typeof(Picker), typeof(OptionPickerRenderer))] +namespace Gallery.iOS.Renderers +{ + public class OptionPickerRenderer : PickerRenderer + { + protected override void OnElementChanged(ElementChangedEventArgs e) + { + base.OnElementChanged(e); + + var control = Control; + if (control != null) + { + control.TextAlignment = UIKit.UITextAlignment.Right; + control.BorderStyle = UIKit.UITextBorderStyle.None; + } + } + } +} diff --git a/Gallery.iOS/Renderers/RoundImageRenderer.cs b/Gallery.iOS/Renderers/RoundImageRenderer.cs new file mode 100644 index 0000000..7ae019c --- /dev/null +++ b/Gallery.iOS/Renderers/RoundImageRenderer.cs @@ -0,0 +1,56 @@ +using CoreAnimation; +using Gallery.iOS.Renderers; +using Gallery.Resources.UI; +using Xamarin.Forms; +using Xamarin.Forms.Platform.iOS; + +[assembly: ExportRenderer(typeof(RoundImage), typeof(RoundImageRenderer))] +namespace Gallery.iOS.Renderers +{ + public class RoundImageRenderer : ImageRenderer + { + protected override void OnElementChanged(ElementChangedEventArgs e) + { + base.OnElementChanged(e); + + var layer = Layer; + if (layer != null && e.NewElement is RoundImage image) + { + bool flag = false; + if (image.CornerRadius > 0) + { + layer.CornerRadius = image.CornerRadius; + flag = true; + } + var mask = image.CornerMasks; + if (mask != CornerMask.None) + { + var m = default(CACornerMask); + if ((mask & CornerMask.LeftTop) == CornerMask.LeftTop) + { + m |= CACornerMask.MinXMinYCorner; + } + if ((mask & CornerMask.RightTop) == CornerMask.RightTop) + { + m |= CACornerMask.MaxXMinYCorner; + } + if ((mask & CornerMask.LeftBottom) == CornerMask.LeftBottom) + { + m |= CACornerMask.MinXMaxYCorner; + } + if ((mask & CornerMask.RightBottom) == CornerMask.RightBottom) + { + m |= CACornerMask.MaxXMaxYCorner; + } + + layer.MaskedCorners = m; + flag = true; + } + if (flag) + { + layer.MasksToBounds = true; + } + } + } + } +} diff --git a/Gallery.iOS/Renderers/RoundLabelRenderer.cs b/Gallery.iOS/Renderers/RoundLabelRenderer.cs new file mode 100644 index 0000000..f3e1310 --- /dev/null +++ b/Gallery.iOS/Renderers/RoundLabelRenderer.cs @@ -0,0 +1,56 @@ +using System.ComponentModel; +using Gallery.iOS.Renderers; +using Gallery.Resources.UI; +using Xamarin.Forms; +using Xamarin.Forms.Platform.iOS; + +[assembly: ExportRenderer(typeof(RoundLabel), typeof(RoundLabelRenderer))] +namespace Gallery.iOS.Renderers +{ + public class RoundLabelRenderer : LabelRenderer + { + protected override void OnElementChanged(ElementChangedEventArgs