Skip to content

Instantly share code, notes, and snippets.

@roubachof
Created September 22, 2023 08:45
Show Gist options
  • Save roubachof/d77099ededf9e75d47453996a3250c75 to your computer and use it in GitHub Desktop.
Save roubachof/d77099ededf9e75d47453996a3250c75 to your computer and use it in GitHub Desktop.
ScrollAware attached properties to implement reveal on scroll UX (twitter, facebook, ...)
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