Created
September 22, 2023 08:45
-
-
Save roubachof/d77099ededf9e75d47453996a3250c75 to your computer and use it in GitHub Desktop.
ScrollAware attached properties to implement reveal on scroll UX (twitter, facebook, ...)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System.Diagnostics.CodeAnalysis; | |
using Microsoft.UI.Xaml; | |
using Microsoft.UI.Xaml.Controls; | |
using System; | |
using System.Diagnostics; | |
using Microsoft.UI.Xaml.Media; | |
using Microsoft.UI.Xaml.Media.Animation; | |
namespace Uno.Toolkit.UI; | |
public static class ScrollAware | |
{ | |
public static DependencyProperty ScrollViewerProperty { [DynamicDependency(nameof(GetScrollViewer))] get; } = DependencyProperty.RegisterAttached( | |
"ScrollViewer", | |
typeof(ScrollViewer), | |
typeof(ScrollAware), | |
new PropertyMetadata(null, OnScrollViewerChanged)); | |
[DynamicDependency(nameof(SetScrollViewer))] | |
public static ScrollViewer GetScrollViewer(FrameworkElement element) => (ScrollViewer)element.GetValue(ScrollViewerProperty); | |
/// <summary> | |
/// Sets the foreground color for the text and icons on the status bar. | |
/// </summary> | |
[DynamicDependency(nameof(GetScrollViewer))] | |
public static void SetScrollViewer(FrameworkElement element, ScrollViewer value) => element.SetValue(ScrollViewerProperty, value); | |
public static DependencyProperty RevealedPositionProperty { [DynamicDependency(nameof(GetRevealedPosition))] get; } = DependencyProperty.RegisterAttached( | |
"RevealedPosition", | |
typeof(RevealedElementPosition), | |
typeof(ScrollAware), | |
new PropertyMetadata(RevealedElementPosition.Unknown)); | |
[DynamicDependency(nameof(SetRevealedPosition))] | |
public static RevealedElementPosition GetRevealedPosition(FrameworkElement element) => (RevealedElementPosition)element.GetValue(RevealedPositionProperty); | |
/// <summary> | |
/// Sets the foreground color for the text and icons on the status bar. | |
/// </summary> | |
[DynamicDependency(nameof(GetRevealedPosition))] | |
public static void SetRevealedPosition(FrameworkElement element, RevealedElementPosition value) => element.SetValue(RevealedPositionProperty, value); | |
public static DependencyProperty ScrollControllerProperty { [DynamicDependency(nameof(GetScrollController))] get; } = DependencyProperty.RegisterAttached( | |
"ScrollAwareController", | |
typeof(ScrollAwareController), | |
typeof(ScrollAware), | |
new PropertyMetadata(null)); | |
[DynamicDependency(nameof(SetScrollController))] | |
public static ScrollAwareController GetScrollController(FrameworkElement element) => (ScrollAwareController)element.GetValue(ScrollControllerProperty); | |
/// <summary> | |
/// Sets the foreground color for the text and icons on the status bar. | |
/// </summary> | |
[DynamicDependency(nameof(GetScrollController))] | |
public static void SetScrollController(FrameworkElement element, ScrollAwareController value) => element.SetValue(ScrollControllerProperty, value); | |
private static void OnScrollViewerChanged(DependencyObject sender, DependencyPropertyChangedEventArgs args) | |
{ | |
if (sender is not FrameworkElement element) | |
{ | |
return; | |
} | |
if (args.OldValue is ScrollViewer oldScrollViewer) | |
{ | |
GetScrollController(element).Unsubscribe(oldScrollViewer); | |
} | |
if (args.NewValue is ScrollViewer newScrollViewer) | |
{ | |
RevealedElementPosition position = GetRevealedPosition(element); | |
if (position != RevealedElementPosition.Unknown) | |
{ | |
SetScrollController(element, new ScrollAwareRevealer(newScrollViewer, element, position)); | |
} | |
} | |
} | |
} | |
public enum RevealedElementPosition | |
{ | |
Unknown = 0, | |
Left, | |
Top, | |
Right, | |
Bottom, | |
} | |
public enum RevealedElementState | |
{ | |
Unknown = 0, | |
Shown, | |
Showing, | |
Hiding, | |
Hidden, | |
} | |
public class ScrollAwareRevealer : ScrollAwareController | |
{ | |
private readonly RevealedElementPosition _revealedElementPosition; | |
private readonly Storyboard _revealStoryboard; | |
private readonly TranslateTransform _translateTransform; | |
private readonly DoubleAnimation _hideAnimation; | |
private readonly DoubleAnimation _opacityAnimation; | |
private RevealedElementState _revealedElementState = RevealedElementState.Shown; | |
public ScrollAwareRevealer(ScrollViewer scrollViewer, FrameworkElement element, RevealedElementPosition revealedElementPosition) | |
: base(scrollViewer, element) | |
{ | |
_revealedElementPosition = revealedElementPosition; | |
_translateTransform = new TranslateTransform(); | |
element.RenderTransform = _translateTransform; | |
_hideAnimation = new DoubleAnimation() | |
{ | |
From = 0, | |
To = 0, | |
Duration = TimeSpan.FromMilliseconds(200), | |
}; | |
_opacityAnimation = new DoubleAnimation() | |
{ | |
From = 1, | |
To = 1, | |
Duration = TimeSpan.FromMilliseconds(200), | |
}; | |
_revealStoryboard = new Storyboard(); | |
Storyboard.SetTarget(_hideAnimation, element); | |
Storyboard.SetTarget(_opacityAnimation, element); | |
switch (_revealedElementPosition) | |
{ | |
case RevealedElementPosition.Top: | |
case RevealedElementPosition.Bottom: | |
Storyboard.SetTargetProperty(_hideAnimation, "(UIElement.RenderTransform).(TranslateTransform.Y)"); | |
break; | |
case RevealedElementPosition.Left: | |
case RevealedElementPosition.Right: | |
Storyboard.SetTargetProperty(_hideAnimation, "(UIElement.RenderTransform).(TranslateTransform.X)"); | |
break; | |
default: | |
throw new ArgumentOutOfRangeException($"Unhandled RevealedElementPosition:{_revealedElementPosition}"); | |
} | |
Storyboard.SetTargetProperty(_opacityAnimation, "Opacity"); | |
_revealStoryboard.Children.Add(_hideAnimation); | |
_revealStoryboard.Children.Add(_opacityAnimation); | |
} | |
protected override void OnScrolled() | |
{ | |
if (OffsetDelta > 5 && _revealedElementState == RevealedElementState.Shown) | |
{ | |
HideElement(); | |
} | |
if (_revealedElementState == RevealedElementState.Hidden && Velocity < -1 || VerticalOffset == 0) | |
{ | |
ShowElement(); | |
} | |
} | |
private void ShowElement() | |
{ | |
if (_revealStoryboard.GetCurrentState() == ClockState.Active || _revealedElementState == RevealedElementState.Shown || _revealedElementState == RevealedElementState.Showing) | |
{ | |
Debug.WriteLine($"[ScrollAwareRevealer] animation running: cancelling"); | |
return; | |
} | |
Debug.WriteLine($"[ScrollAwareRevealer] ShowElement"); | |
_revealedElementState = RevealedElementState.Showing; | |
_hideAnimation.From = _revealedElementPosition switch | |
{ | |
RevealedElementPosition.Top => -Element.ActualHeight, | |
RevealedElementPosition.Bottom => Element.ActualHeight, | |
RevealedElementPosition.Left => -Element.ActualWidth, | |
RevealedElementPosition.Right => Element.ActualWidth, | |
_ => throw new ArgumentOutOfRangeException($"Unhandled RevealedElementPosition:{_revealedElementPosition}"), | |
}; | |
_hideAnimation.To = 0; | |
_opacityAnimation.From = 0; | |
_opacityAnimation.To = 1; | |
_revealStoryboard.Begin(); | |
void OnRevealStoryboardOnCompleted(object s, object e) | |
{ | |
_revealStoryboard.Completed -= OnRevealStoryboardOnCompleted; | |
_revealedElementState = RevealedElementState.Shown; | |
Debug.WriteLine($"[ScrollAwareRevealer] Set shown"); | |
} | |
_revealStoryboard.Completed += OnRevealStoryboardOnCompleted; | |
} | |
private void HideElement() | |
{ | |
if (_revealStoryboard.GetCurrentState() == ClockState.Active || _revealedElementState == RevealedElementState.Hidden || _revealedElementState == RevealedElementState.Hiding) | |
{ | |
Debug.WriteLine($"[ScrollAwareRevealer] animation running: cancelling"); | |
return; | |
} | |
Debug.WriteLine($"[ScrollAwareRevealer] HideElement"); | |
_revealedElementState = RevealedElementState.Hiding; | |
_hideAnimation.From = 0; | |
_hideAnimation.To = _revealedElementPosition switch | |
{ | |
RevealedElementPosition.Top => -Element.ActualHeight, | |
RevealedElementPosition.Bottom => Element.ActualHeight, | |
RevealedElementPosition.Left => -Element.ActualWidth, | |
RevealedElementPosition.Right => Element.ActualWidth, | |
_ => throw new ArgumentOutOfRangeException( | |
$"Unhandled RevealedElementPosition:{_revealedElementPosition}") | |
}; | |
_opacityAnimation.From = 1; | |
_opacityAnimation.To = 0; | |
_revealStoryboard.Begin(); | |
void OnRevealStoryboardOnCompleted(object s, object e) | |
{ | |
_revealStoryboard.Completed -= OnRevealStoryboardOnCompleted; | |
_revealedElementState = RevealedElementState.Hidden; | |
Debug.WriteLine($"[ScrollAwareRevealer] Set hidden"); | |
} | |
_revealStoryboard.Completed += OnRevealStoryboardOnCompleted; | |
} | |
} | |
public abstract class ScrollAwareController | |
{ | |
private readonly ScrollViewer _scrollViewer; | |
private double _previousVerticalOffset = -1; | |
private long _previousMeasureTime = 0; | |
private double _previousVelocity = 0; | |
protected ScrollAwareController(ScrollViewer scrollViewer, FrameworkElement element) | |
{ | |
_scrollViewer = scrollViewer; | |
Element = element; | |
_scrollViewer.ViewChanged += OnViewChanged; | |
_previousVerticalOffset = scrollViewer.VerticalOffset; | |
_previousMeasureTime = DateTime.UtcNow.Ticks; | |
} | |
public void Unsubscribe(ScrollViewer scrollViewer) | |
{ | |
if (scrollViewer != _scrollViewer) | |
{ | |
return; | |
} | |
_scrollViewer.ViewChanged -= OnViewChanged; | |
} | |
protected FrameworkElement Element { get; } | |
protected double Velocity { get; private set;} | |
protected double OffsetDelta { get; private set;} | |
protected double VerticalOffset { get; private set; } | |
protected abstract void OnScrolled(); | |
private void OnViewChanged(object sender, ScrollViewerViewChangedEventArgs e) | |
{ | |
var scrollViewer = (ScrollViewer)sender; | |
if (_previousVerticalOffset > -1) | |
{ | |
long currentTime = DateTime.UtcNow.Ticks; | |
VerticalOffset = scrollViewer.VerticalOffset; | |
Velocity = ComputeVelocity(_previousVerticalOffset, VerticalOffset, _previousMeasureTime, currentTime); | |
OnScrolled(); | |
Debug.WriteLine($"[ScrollAwareController] Offset: {VerticalOffset}, Velocity: {_previousVelocity}"); | |
_previousVelocity = Velocity; | |
_previousVerticalOffset = VerticalOffset; | |
_previousMeasureTime = currentTime; | |
} | |
else { | |
_previousVerticalOffset = scrollViewer.VerticalOffset; | |
_previousMeasureTime = DateTime.UtcNow.Ticks; | |
} | |
} | |
double ComputeVelocity(double previousOffset, double currentOffset, long previousTime, long currentTime) | |
{ | |
OffsetDelta = currentOffset - previousOffset; | |
double elapsedMilliseconds = (currentTime - previousTime) / 10000d; | |
Debug.WriteLine($"[ScrollAwareController] offsetDelta: {OffsetDelta}, elapsedMilliseconds: {elapsedMilliseconds}"); | |
return OffsetDelta / elapsedMilliseconds; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment