Как мы делали приложение под Windows 10 с Fluent Design (UWP-C#)

в 6:15, , рубрики: C#, composition, fluent design system, ivi, uwp, Windows 10, XAML, анимации, Блог компании онлайн-кинотеатр ivi, разработка под windows

Мы в ivi давно собирались обновить наше приложение под Windows 10 (которое для ПК и планшетов). Мы хотели сделать его эдаким «уютным» уголком для отдыха. И поэтому анонсированная недавно Microsoft-ом концепция fluent design пришлась нам очень кстати.

Но я не буду здесь рассказывать про стандартные компоненты, предлагаем Microsoft-ом для fluent design-а (Acrylic, Reveal, Connected-анимации и др.), хотя мы их, конечно, используем тоже. С ними всё просто и понятно — бери документацию и пользуйся. 

Но приключения обычно начинаются тогда, когда ты уходишь с проторенной дорожки. Поэтому я лучше расскажу про то, как мы делали один кастомный контрол, который доставил нам много хлопот. Вот такой:

image

Идея в том, что мы используем depth и motion из fluent design system. Центральный элемент как бы слегка приподнимается надо всеми остальными. Это достигается за счёт анимации его размера и тени во время скролла. 

Контрол FlipView сразу не подошёл, т.к. он не умеет показывать кусочки следующего и предыдущего элементов (мы их называем «ушами»). И мы начали поиски решения.

Путь 1. Пробуем использовать GridView

Логичным решением было попробовать использовать GridView. Чтобы выстроить элементы в горизонтальную строку задаём в качестве ItemsPanel задаём:

<ItemsStackPanel Orientation="Horizontal" />

Чтобы центрировать текущий элемент используем свойства ScrollViewer-а в шаблоне GridView: 

<ScrollViewer HorizontalSnapPointsType="MandatorySingle"
              HorizontalSnapPointsAlignment="Center" />

Пример такой реализации можно посмотреть, например, здесь.

Вроде всё ок, но есть проблемы.

GridView. Проблема 1. масштабирование контрола на всю ширину экрана

По задумке дизайнеров наш контрол должен тянуться на всю ширину окна. Само по себе это не проблема. Но при изменении размера контрола должны синхронно изменяться и размеры всех его дочерних элементов (Items):

  • Ширину элементов надо устанавливать в 90% ширины контрола (10% оставляем на «уши»);
  • Высоту элементов надо рассчитывать исходя из ширины и пропорций изображения;
  • При этом на малой ширине экрана нужно обрезать изображение слева и справа, чтобы оно не стало слишком мелким после масштабирования.

image

Из коробки GridView такого не умеет. Решение мы подсмотрели в реализации контрола AdaptiveGridView из UWPToolkit:

  • Наследуемся от GridView и добавляем два свойства: ItemWidth и ItemHeight;
  • В обработчике события SizeChanged рассчитываем эти свойства в зависимости от ширины GridView;
  • Переопределяем метод PrepareContainerForItemOverride у GridView. Он вызывается для каждого ItemContainer-а прежде, чем тот будет показан пользователю. И добавляем для каждого item-а биндинги на созданные нами ItemWidth и ItemHeight:

protected override void PrepareContainerForItemOverride(DependencyObject obj, object item)
{
    base.PrepareContainerForItemOverride(obj, item);
    if (obj is FrameworkElement element)
    {
        var heightBinding = new Binding()
        {
            Source = this,
            Path = new PropertyPath("ItemHeight"),
            Mode = BindingMode.TwoWay
        };
        var widthBinding = new Binding()
        {
            Source = this,
            Path = new PropertyPath("ItemWidth"),
            Mode = BindingMode.TwoWay
        };

        element.SetBinding(HeightProperty, heightBinding);
        element.SetBinding(WidthProperty, widthBinding);
    }
}

Более подробно реализацию можно посмотреть в исходниках UWPToolkit.

Вроде всё ок, работает. Но…

GridView. Проблема 2. При изменении размера item-ов текущий элемент уходит из области видимости

Но как только мы начинаем динамически изменять ширину элементов внутри GridView, мы сталкиваемся со следующей проблемой. В этот момент в видимую область начинает попадать совсем другой элемент. Это происходит из-за того, что HorizontalOffset у ScrollViewer-а внутри GridView остаётся неизменным. GridView не предполагает такого подвоха от нас. 

image

Особенно сильно эффект заметен при максимизации окна (из-за резкого изменения размеров). И ещё при просто больших значениях HorizontalOffset.

Казалось бы можно было бы это проблему решить попросив GridView проскроллиться к нужному элементу:

private async void OnSizeChanged(object sender, SizeChangedEventArgs e)
{
    ...
    await Task.Yield();
    this.ScrollIntoView(getCurrentItem(), ScrollIntoViewAlignment.Default);
}

Но нет:

  • Без использования Task.Yield() это не работает. А с ним — приводит к некрасивому визуальному подергиванию — т.к. другой элемент успевает отобразиться прежде, чем отработает ScrollIntoView.
  • А при включенных SnapPoints ScrollIntoView почему-то в принципе работает некорректно. Как будто застревает на них.

Ещё это можно было бы решить, вручную вычисляя и устанавливая новое значение HorizontalOffset у ScrollViewer-а при каждом изменении размера нашего GridView:

private void OnSizeChanged(object sender, SizeChangedEventArgs e)
{
    ...
    var scrollViewer = this.FindDescendant<ScrollViewer>();
    scrollViewer.ChangeView(calculateNewHorizontalOffset(...), 0, 1, true);
}

Но это работает только при постепенном изменении размера окна. При максимизации окна это часто даёт неправильный результат. Скорее всего, причина в том, что новое рассчитанное нами значение HorizontalOffset оказывается слишком большим и выходит за границы ExtentWidth (ширины контента внутри ScrollViewer-а). А т.к. GridView использует UI-виртуализацию, то автоматически после изменения ширины Item-ов ExtentWidth может не пересчитываться. 
 
В общем, адекватного решения этой проблемы найти не удалось. 

На этом можно было бы остановиться и начать поиск следующего варианта решения. Но я опишу ещё одну проблему с этим подходом.

GridView. Проблема 3. Вложенные ScrollViewer-ы ломают горизонтальный скроллинг мышкой

Мы хотим, чтобы колёсико мышки всегда выполняло скроллинг по вертикали. Всегда. По вертикали.

Но если мы ставим на страницу GridView с горизонтальной прокруткой, то находящийся в его недрах ScrollViewer захватывает события колёсика мышки на себя и не пропускает выше. В итоге, если курсор мышки находится над нашим контролом-листалкой, то колёсико мышки делает горизонтальный скролл в нём. Это неудобно и запутывает пользователей:

image

Решений у этой проблемы два:

  • Ловить событие PointerWheelChanged до его попадания в горизонтальный ScrollViewer и в ответ на него вызывать ChangeView() у вертикального ScrollViewer-а. Подсмотрено здесь. Это работает, но заметно тормозит при быстром вращении колёсика мыши. Нам не подошло — портить скролл мышкой ради редких пользователей с touch screen — не вариант.
  • Установить HorizontalScrollMode="Disabled". Это помогает, но это отключает не только колёсико мышки, но скроллинг через touch screen.
     <GridView ScrollViewer.HorizontalScrollMode="Disabled" />

Touch screen терять не хотелось и мы продолжили поиск более хорошего решения.

Путь 2. Carousel из UWPToolkit 

Следующим вариантом решения стал контрол Carousel из UWPToolkit. Со всех сторон очень интересный и познавательный контрол. Рекомендую всем для изучения его реализацию.

Он довольно неплохо закрывал наши потребности. Но в итоге тоже не подошёл:

  • В нём отсутствует масштабирование элементов при изменении ширины (см. выше):
    • Это решаемся проблема. Т.к. он open source. И добавить в него масштабирование будет не сложно. 
    • И даже проблема сохранения текущего элемента в области видимости после масштабирования тоже решаемая, опять же благодаря open source реализации.
  • Отсутствует UI-виртуализация:
    • Carousel использует свою собственную реализацию ItemsPanel. И поддержки UI-виртуализации в ней нет;
    • Это довольно критичная для нас штука, т.к. промо-материалов в листалке у нас может быть довольно много и это сильно влияет на время загрузки страницы;
    • Да, это, наверно, тоже реализуемо. Но уже не выглядит простым.
  • Он использует анимации на UI-потоке (Storyboard-ы и события Manipulation*), что, по определению, не всегда достаточно плавно.

Т.е. получается, что нужно потратить довольно много времени на доработку этого контрола под наши нужды (одна UI-виртуализация чего стоит). При этом на выходе мы получим штуку с потенциально подтормаживающими анимациями. 

В общем, от этого подхода мы тоже решили отказаться. Если уж тратить время, то будем делать по уму.

Путь 3. Наша реализация

Делаем «TOUCH ONLY» ScrollViewer

Напомню, что стандартный ScrollViewer мы использовать не хотим, из-за того, что он захватывает все события от колёсика мышки (см. выше раздел «GridView. Проблема 3»).

Реализацию из Carousel нам не нравится, т.к. использует анимации на UI-потоке, а предпочтительный для UWP-приложений способ создания анимаций — это Composition-анимации. Их отличие от более привычных Storyboard-ов в том, что они работают на отдельном Composition-потоке и за счёт этого обеспечивают 60 кадров/сек даже тогда, когда UI поток чем-то занят.

Для реализации нашей задачи нам понадобится InteractionTracker — компонент, который позволяет использовать touch-ввод в качестве источника для анимаций. Собственно, первое, что нам нужно научиться делать — это перемещать UI-элементы по горизонтали в зависимости от перемещения пальца по экрану. Фактически, нам придётся начать с реализации своего кастомного ScrollViewer-а. Так и назовём его — TouchOnlyScrollViewer:

public class TouchOnlyScrollerViewer : ContentControl
{
    private Visual _thisVisual;
    private Compositor _compositor;
    private InteractionTracker _tracker;
    private VisualInteractionSource _interactionSource;
    private ExpressionAnimation _positionExpression;
    private InteractionTrackerOwner _interactionTrackerOwner;

    public double HorizontalOffset { get; private set; }
    public event Action<double> ViewChanging;
    public event Action<double> ViewChanged;

    public TouchOnlyScrollerViewer()
    {
        initInteractionTracker();
        Loaded += onLoaded;
        PointerPressed += onPointerPressed;
    }

    private void initInteractionTracker()
    {
        // Инициализируем InteractionTracker и VisualInteractionSource
        _thisVisual = ElementCompositionPreview.GetElementVisual(this);
        _compositor = _thisVisual.Compositor;
        _tracker = InteractionTracker.Create(_compositor);
        _interactionSource = VisualInteractionSource.Create(_thisVisual);
        _interactionSource.PositionXSourceMode =
           InteractionSourceMode.EnabledWithInertia;
        _tracker.InteractionSources.Add(_interactionSource);

        // Создаём тривиальную Expression-анимацию, которая в качестве источника 
        // использует touch-смещение из InteractionTracker
        _positionExpression = 
           _compositor.CreateExpressionAnimation("-tracker.Position");
        _positionExpression.SetReferenceParameter("tracker", _tracker);
    }

    private void onLoaded(object sender, RoutedEventArgs e)
    {
        // Привязываем нашу анимацию к свойству Offset дочернего UIElement-а
        var visual = ElementCompositionPreview.GetElementVisual((UIElement)Content);
        visual.StartAnimation("Offset", _positionExpression);
    }

    private void onPointerPressed(object sender, PointerRoutedEventArgs e)
    {
        // перенаправляем touch-ввод в composition-поток
        if (e.Pointer.PointerDeviceType == PointerDeviceType.Touch)
        {
            try
            {
                _interactionSource.TryRedirectForManipulation(e.GetCurrentPoint(this));
            }
            catch (Exception ex)
            {
                Debug.WriteLine("TryRedirectForManipulation: " + ex.ToString());
            }
        }
    }
}

Здесь пока всё строго по доке от Mircosoft. Разве что вызов TryRedirectForManipulation пришлось обернуть в try-catch потому что он иногда кидает внезапные исключения. Это случается довольно редко (навскидку, примерно в 2-5% случаев) и выяснить причину нам не удалось. Почему об этом ничего не сказано в документации и официальных примерах Microsoft — мы не знаем ;)

TOUCH ONLY ScrollViewer. Формируем HorizontalOffset и события ViewChanging и ViewChanged

Раз мы делаем подобие ScrollViewer-а, то нам понадобится свойство HorizontalOffset и события ViewChanging и ViewChanged. Их будем реализовывать через обработку callback-ов InteractionTracker-а. Для их получения при создании InteractionTracker-а надо указать объект, реализующих IInteractionTrackerOwner, который эти callback-и и будет получать:

_interactionTrackerOwner = new InteractionTrackerOwner(this);
_tracker = InteractionTracker.CreateWithOwner(_compositor, _interactionTrackerOwner);

Для полноты картины позволю себе скопировать картинку из документации с состояниями и событиями InteractionTracker-а:

image

Событие ViewChanged будем бросать по входу в состояние Idle.

Событие ViewChanging будем бросать по срабатыванию IInteractionTrackerOwner.ValuesChanged.
Сразу скажу, что ValuesChanged может случаться, когда InteractionTracker находится и в состоянии Idle. Это случается после вызова TryUpdatePosition у InteractionTracker-а. И выглядит как баг в платформе UWP.

Ну что ж, с этим придётся мириться. Благо, нам не сложно — в ответ на ValuesChanged будем выбрасывать либо ViewChanging, либо ValuesChanged, в зависимости от текущего состояния:

private class InteractionTrackerOwner : IInteractionTrackerOwner
{
    private readonly TouchOnlyScrollerViewer _scrollViewer;

    public void ValuesChanged(InteractionTracker sender,
                              InteractionTrackerValuesChangedArgs args)
    {
        // Сохраняем текущее смещение. Пригодится для будущих нужд.
        _scrollViewer.HorizontalOffset = args.Position.X;

        if (_interactionTrackerState != InteractionTrackerState.Idle)
        {
            _scrollViewer.ViewChanging?.Invoke(args.Position.X);
        }
        else
        {
            _scrollViewer.ViewChanged?.Invoke(args.Position.X);
        }
    }

    public void IdleStateEntered(InteractionTracker sender,
                                 InteractionTrackerIdleStateEnteredArgs args)
    {
        // Здесь нельзя использовать _scrollViewer._tracker.Position. 
        // В Windows 14393 (Anniversary Update) он почему-то всегда 0
        _scrollViewer.ViewChanged?.Invoke(_scrollViewer.HorizontalOffset, requestType);
    }
}

TOUCH ONLY ScrollViewer. Snap Points, чтобы пролистывалось ровно на 1 элемент

Для обеспечения пролистывания ровно на 1 элемент есть замечательное решение — «snap points with inertia modifiers».

Смысл в том, что мы задаём точки, в которых скроллинг имеет право остановиться после выполнения свайпа на touch экране. А всю остальную логику берёт на себя InteractionTracker. По сути он модифицирует скорость замедления так, чтобы остановка после свайпа произошла плавно и при этом ровно в том месте, где нам надо.

Наша реализация чуть отличается от той, что изложена в примере в документации. Потому что мы не хотим давать скроллить более чем на один элемент за раз, даже если пользователь «крутанул» нашу листалку слишком быстро. 

Поэтому мы добавляем только три snap-point-а — «на один шаг влево», «на один шаг вправо» и «остаться в текущей позиции». И после каждого пролистывания будем их обновлять.

А чтобы не пересоздавать snap point-ы каждый раз после прокрутки, мы сделаем их параметризуемыми. Для этого заводим PropertySet с тремя свойствами:

    _snapPointProps = _compositor.CreatePropertySet();
    _snapPointProps.InsertScalar("offsetLeft", 0);
    _snapPointProps.InsertScalar("offsetCurrent", 0);
    _snapPointProps.InsertScalar("offsetRight", 0);

И в формулах для Condition и RestingValue используем свойства из этого PropertySet:

    // Точка привязки на «на один шаг влево»
    var leftSnap = InteractionTrackerInertiaRestingValue.Create(_compositor);
    leftSnap.Condition = _compositor.CreateExpressionAnimation(
       "this.Target.NaturalRestingPosition.x < " +    
       "props.offsetLeft * 0.25 + props.offsetCurrent * 0.75");
    leftSnap.Condition.SetReferenceParameter("props", _snapPointProps);
    leftSnap.RestingValue = 
       _compositor.CreateExpressionAnimation("props.offsetLeft");
    leftSnap.RestingValue.SetReferenceParameter("props", _snapPointProps);

    // Точка привязки на «на один шаг вправо»
    var currentSnap = InteractionTrackerInertiaRestingValue.Create(_compositor);
    currentSnap.Condition = _compositor.CreateExpressionAnimation(
        "this.Target.NaturalRestingPosition.x >= " +
            "props.offsetLeft * 0.25 + props.offsetCurrent * 0.75 && " +
        "this.Target.NaturalRestingPosition.x < " +
            "props.offsetCurrent * 0.75 + props.offsetRight * 0.25");
    currentSnap.Condition.SetReferenceParameter("props", _snapPointProps);
    currentSnap.RestingValue = 
        _compositor.CreateExpressionAnimation("props.offsetCurrent");
    currentSnap.RestingValue.SetReferenceParameter("props", _snapPointProps);

    // Точка привязки «на один шаг вправо»
    var rightSnap = InteractionTrackerInertiaRestingValue.Create(_compositor);
    rightSnap.Condition = _compositor.CreateExpressionAnimation(
        "this.Target.NaturalRestingPosition.x >= " +
        "props.offsetCurrent * 0.75 + props.offsetRight * 0.25");
    rightSnap.Condition.SetReferenceParameter("props", _snapPointProps);
    rightSnap.RestingValue = 
         _compositor.CreateExpressionAnimation("props.offsetRight");
    rightSnap.RestingValue.SetReferenceParameter("props", _snapPointProps);

    _tracker.ConfigurePositionXInertiaModifiers(
        new InteractionTrackerInertiaModifier[] { leftSnap, currentSnap, rightSnap });
}

Здесь:

  • NaturalRestingPosition.X — это смещение, на котором закончилась бы инерция, если бы не было snap points;
  • SnapPoint.RestingValue — смещение, на котором разрешена остановка при выполнении условия SnapPoint.Condition.

Сначала мы пробовали границу в Condition ставить посередине между snap point-ами, но пользователи замечали, что почему-то далеко не каждый свайп вызывал пролистывание к следующему элементу. Некоторые свайпы оказывались недостаточно быстрыми и происходил откат назад.

Поэтому в формулах для Contition мы используем коэффициенты 0.25 и 0.75, чтобы даже «медленный» свайп производил прокрутку к соседнему элементу. 

Ну и после каждой прокрутки к соседнему элементу будем вызывать вот такой метод для обновления параметров snap point-ов:

public void SetSnapPoints(double left, double current, double right)
{
    _snapPointProps.InsertScalar("offsetLeft", (float)Math.Max(left, 0));
    _snapPointProps.InsertScalar("offsetCurrent", (float)current);
    _snapPointProps.InsertScalar("offsetRight",
        (float)Math.Min(right, _tracker.MaxPosition.X));
}

Панель с UI-виртуализацией

Следующим шагом нам нужно было построить на основе нашего TouchOnlyScrollerViewer-а полноценный ItemsControl. Т.к. всё-таки очень не хотелось реализовывать свою UI-виртуализацию, то первое, что мы, конечно, попытались сделать — это использовать стандартную панель ItemsStackPanel.

Хотелось подружить её с нашим TouchOnlyScrollerViewer-ом. К сожалению, не удалось найти ни документации об её внутреннем устройстве, ни исходного кода. Но ряд экспериментов позволил предположить, что ItemsStackPanel ищет ScrollViewer в Visual Tree в списке родительских элементов. И способа это как-то переопределить, чтобы вместо стандартного ScrollViewer-а оно искало наш, мы не нашли.

Ну что ж. Значит панель с UI-виртуализацией придётся всё-таки делать самостоятельно. Лучшее, что удалось найти на эту тему — это вот этот цикл статей аж 11-летней давности: раз, два, три, четыре. Он, правда, про WPF, а не про UWP, но идею передаёт очень неплохо. Ей мы и воспользовались.

Собственно, идея проста:

  • Такая панель вкладывается внутрь нашего TouchOnlyScrollerViewer-а и подписывается на его события ViewChanging и ViewChanged;
  • Панель создаёт ограниченное кол-во дочерних UI-элементов. В нашем случае — это 5 (один в центре, два — на торчащие слева и справа «уши», и ещё 2 — для кэша следующих за «ушами» элементов);
  • UI-элементы позиционируются в зависимости от TouchOnlyScrollerViewer.HorizontalOffset и пере-привязываются к нужным дата-объектам по мере скроллинга.

Реализацию показывать не буду, т.к. она получилась достаточно сложной. Это скорее тема для отдельной статьи.

Ищем события Tapped, потерявшиеся после перенаправления touch-ввода в composition-поток

После того, как мы это собрали вместе вскрылась ещё одна интересная проблема. Иногда пользователь тапает по элементам внутри нашего контрола в процессе того, пока touch ввод перенаправлен в InteractionTracker. Это случается, когда происходит скроллинг по инерции. В этом случае события PointerPressed, PointerReleased и Tapped просто не случаются. И это не надуманная проблема, т.к. инерция у InteractionTracker-а довольно долгая. И даже, когда визуально скроллинг почти закончился, по факту может происходит медленное доскролливание последних нескольких пикселей.

В итоге пользователь расстраивается — он ожидает, что по тапу откроется страница выбранного фильма. Но этого не происходит.

Поэтому будем идентифицировать тап по паре событий от InteractionTracker-а: 

  • Переход в состояние Interacting (палец коснулся экрана);
  • Затем сразу (менее чем за 150ms) переход в состояние Inertia (палец отпустил экран). И при этом скорость скроллинга должна быть нулевой (иначе это уже не тап, а свайп):

public void InertiaStateEntered(InteractionTracker sender,
                                InteractionTrackerInertiaStateEnteredArgs args)
{
    if (_interactionTrackerState == InteractionTrackerState.Interacting
        && (DateTime.Now - _lastStateTime) < TimeSpan.FromMilliseconds(150)
        && Math.Abs(args.PositionVelocityInPixelsPerSecond.X) < 1 /* 1px/sec */)
    {
        _scrollViewer.TappedCustom?.Invoke(_scrollViewer.HorizontalOffset);
    }
    _interactionTrackerState = InteractionTrackerState.Inertia;
    _lastStateTime = DateTime.Now;
}

Это работает. Но, правда, не позволяет узнать элемент, по которому был осуществлён тап. В нашем случае это не критично, т.к. наши элементы занимают почти всю видимую ширину TouchOnlyScrollViewer-а. Поэтому мы просто выбираем тот, что находится ближе к центру. В большинстве случаев — это именно то, что нужно. Пока никто даже не заметил, что иногда тап во время скроллинга может привести не туда. Это не так просто поймать, даже если знаешь про это ;)

Хотя в общем случае, это конечно ещё не полноценное решение. Для полноценной реализации пришлось бы городить ещё и свой hit testing. Но и его непонятно как сделать, т.к. координаты тапа неизвестны…

Бонус. Expression-анимации для opacity, scale и теней. Чтобы стало, наконец, красиво

И, наконец, вишенка на торте — то, ради чего всё это и затевалось. По мере скроллинга мы хотим изменять размер, тень и прозрачность элементов. Чтобы создать ощущение, что тот, который в центре — слегка приподнят.

Для этого мы воспользуемся Expression-анимациями. Они тоже являются частью подсистемы Composition, работают на отдельном потоке и поэтому не подтормаживают при занятости UI-потока.

Создаются они так. Для свойства, которое должно анимироваться мы задаём формулу (expression), которая определяет зависимость этого свойства от каких-либо других свойств. Формула задаётся в виде текстовой строки.

Ещё их прелесть в том, что их можно выстраивать в цепочки. Этим мы и воспользуемся:
image

Источником для всех анимаций будет смещение из InteractionTracker-а в пикселях. На основе него мы для каждого дочернего UI-элемента сгенерируем свойство progress, которое будет принимать значения в диапазоне от 0 до 1. И уже на основе progress-а будем вычислять все остальные визуальные свойства.

Итак, формируем _progressExpression таким образом, чтобы оно принимало значения:

  • 0 — если наш элемент ушёл достаточно далеко и достиг своего минимально размера и минимальной тени;
  • 1 — если наш элемент находится чётко на центральной позиции, в этот момент он имеет максимальный размер, а тень показывает, что он как бы поднят:

_progressExpression = _compositor.CreateExpressionAnimation(
   "1 - " +
   "Clamp(Abs(tracker.Position.X - props.offsetWhenSelected), 0, props.maxDistance)"
   + " / props.maxDistance");

Здесь:

  • Clamp(val, min, max) — системная функция. Если val выходит за эти рамки min/max, то возвращает min/max. Если не выходит — возвращает val.
  • offsetWhenSelected — смещение InteractionTracker-а, при котором текущий элемент находится строго в центре видимой области;
  • maxDistance — расстояние при удалении на котором текущий элемент принимает минимальный размер;
  • tracker — наш InteractionTracker.

Добавляем все эти параметры в нашу Expression-анимацию:

_progressExpression.SetReferenceParameter("tracker", tracker);
_props = _compositor.CreatePropertySet();
_props.InsertScalar("offsetWhenSelected", (float)offsetWhenSelected);
_props.InsertScalar("maxDistance", getMaxDistanceParam());
_progressExpression.SetReferenceParameter("props", _props);

И создаём PropertySet со свойством progress, которое будет вычисляться посредством нашего _progressExpression. Это нужно, чтобы на основе этого свойства строить следующие анимации:

_progressProps = _compositor.CreatePropertySet();
_progressProps.InsertScalar("progress", 0f);
_progressProps.StartAnimation("progress", _progressExpression);

Теперь на основе нашего свойства progress создаём уже настоящие «визуальные» анимации с использованием линейной интерполяции (системные функции Lerp и ColorLerp). Полный список функций, которые можно использовать в Expression-анимациях можно найти здесь.

Масштабирование:

_scaleExpression = _compositor.CreateExpressionAnimation(
    "Vector3(Lerp(earUnfocusScale, 1, props.progress), " +
            "Lerp(earUnfocusScale, 1, props.progress), 1)");
_scaleExpression.SetScalarParameter("earUnfocusScale", (float)_earUnfocusScale);
_scaleExpression.SetReferenceParameter("props", _progressProps);
_thisVisual.StartAnimation("Scale", _scaleExpression);

Радиус тени:

_shadowBlurRadiusExpression = _compositor.CreateExpressionAnimation(
    "Lerp(blur1, blur2, props.progress)");
_shadowBlurRadiusExpression.SetScalarParameter("blur1", ShadowBlurRadius1);
_shadowBlurRadiusExpression.SetScalarParameter("blur2", ShadowBlurRadius2);
_shadowBlurRadiusExpression.SetReferenceParameter("props", _progressProps);
_dropShadow.StartAnimation("BlurRadius", _shadowBlurRadiusExpression);

Цвет тени:

_shadowColorExpression = _compositor.CreateExpressionAnimation(
    "ColorLerp(color1, color2, props.progress)"))
_shadowColorExpression.SetColorParameter("color1", ShadowColor1);
_shadowColorExpression.SetColorParameter("color2", ShadowColor2);
_shadowColorExpression.SetReferenceParameter("props", _progressProps);
_dropShadow.StartAnimation("Color", _shadowColorExpression);

Ну и для остальных свойств формулы аналогичны.

Эпилог

На этом всё. Справедливости ради, надо сказать, что этот контрол оказался, пожалуй, чуть ли ни самым сложным с точки зрения реализации. Остальной fluent design дался намного проще :)

→ Посмотреть, как это всё работает можно, установив приложение.

Автор: Вячеслав Климентьев

Источник

Поделиться

* - обязательные к заполнению поля