adjust UI

This commit is contained in:
2021-08-10 17:17:32 +08:00
parent f8850073cd
commit 6507f7cadf
100 changed files with 3138 additions and 963 deletions

View File

@ -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;
}
}
}
}

View File

@ -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);
}
}
}

View File

@ -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()

View File

@ -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<GalleryItem[]>
{
protected readonly IGallerySource source;
public GalleryCollectionPage(IGallerySource source)
{
this.source = source;
}
}
public interface IGalleryCollectionPage
{
GalleryCollection GalleryCollection { get; set; }
}
public abstract class GalleryCollectionPage<T> : 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<T>));
public static readonly BindableProperty ColumnsProperty = BindableProperty.Create(nameof(Columns), typeof(int), typeof(GalleryCollectionPage<T>),
defaultValue: 2);
public static readonly BindableProperty IsLoadingProperty = BindableProperty.Create(nameof(IsLoading), typeof(bool), typeof(GalleryCollectionPage<T>),
defaultValue: true);
public static readonly BindableProperty IsBottomLoadingProperty = BindableProperty.Create(nameof(IsBottomLoading), typeof(bool), typeof(GalleryCollectionPage<T>));
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<GalleryItem> commandGalleryItemTapped;
protected DateTime lastUpdated;
protected double topOffset;
protected string lastError;
private readonly object sync = new();
private readonly Stack<ParallelTask> tasks = new();
private T galleryData;
public GalleryCollectionPage()
{
commandGalleryItemTapped = new Command<GalleryItem>(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<T> DoloadGalleryData(bool force);
protected abstract IEnumerable<GalleryItem> 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<T> : GalleryCollectionPage<T>
{
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
}
}