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> { public GalleryCollectionPage(IGallerySource source) : base(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 IGallerySource Source { get; } public GalleryCollection GalleryCollection { get; set; } public DateTime LastUpdated { get { if (App.RefreshTimes.TryGetValue(Source.Route, out var time)) { #if DEBUG Log.Print($"get last updated time for: {Source.Route}, {time}"); #endif return time; } #if DEBUG Log.Print($"cannot get last updated time for: {Source.Route}"); #endif return default; } set { #if DEBUG Log.Print($"set last updated time for: {Source.Route} to {value}"); #endif App.RefreshTimes[Source.Route] = value; } } protected virtual ActivityIndicator LoadingIndicator => null; protected virtual double IndicatorMarginTop => 16; protected bool Expired { get { var lastUpdated = LastUpdated; return lastUpdated == default || (DateTime.Now - lastUpdated).TotalMinutes > EXPIRED_MINUTES; } } protected double topOffset; protected string lastError; private readonly object sync = new(); private readonly Command commandGalleryItemTapped; private readonly Stack tasks = new(); private T galleryData; public GalleryCollectionPage(IGallerySource source) { Source = source; commandGalleryItemTapped = new Command(OnGalleryItemTapped); } protected virtual void OnGalleryItemTapped(GalleryItem item) { } 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 (Expired) { StartLoading(); } else if (GalleryCollection != null) { var favorites = Store.FavoriteList; foreach (var item in GalleryCollection) { item.IsFavorite = favorites.Any(i => i.SourceEquals(item)); } } } #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) { if (!isBottom) { lock (sync) { // destory all the tasks while (tasks.TryPop(out var t)) { t?.Dispose(); } } } 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); }); } BeforeLoading(); } } 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; AfterLoaded(); 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; AfterLoaded(); return false; }); }); } } protected virtual void BeforeLoading() { } protected virtual void AfterLoaded() { } 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 || Expired) { LastUpdated = DateTime.Now; } var data = DoGetGalleryList(galleryData, out int tag).Where(i => i != null); var collection = new GalleryCollection(data); var favorites = Store.FavoriteList; foreach (var item in collection) { if (item.PreviewImage == null) { var image = await Store.LoadRawImage(item, false); if (image != null) { item.PreviewImage = image; } else { image = await Store.LoadPreviewImage(item, false); if (image != null) { item.PreviewImage = image; } } } item.IsFavorite = favorites.Any(i => i.SourceEquals(item)); } 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, Config.DownloadThreads, i => { if (!collection.Running) { return false; } var item = list[i]; if (item.PreviewImage == null && item.PreviewUrl != null) { item.PreviewImage = Definition.DownloadBackground; #if DEBUG var model = Xamarin.Essentials.DeviceInfo.Model; if (model.StartsWith("iPhone") || model.StartsWith("iPad")) { #endif var image = Store.LoadPreviewImage(item, true, force: true).Result; if (image != null) { item.PreviewImage = image; } #if DEBUG } #endif } 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; public GalleryScrollableCollectionPage(IGallerySource source) : base(source) { } 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: force, isBottom: 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(force: true, isBottom: true); } } } } } public enum ScrollDirection { Stop, Up, Down } }