Мы в 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-виртуализация — это когда контрол-список вместо, скажем, 1000 дочерних элементов создаёт только те, что видны на экране. И переиспользует их по мере скроллинга, привязывая к новым дата-объектам. Это позволяет сократить время загрузки страницы при большом количестве элементов в списке.

Т.к. всё-таки очень не хотелось реализовывать свою 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 дался намного проще :)

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

Комментарии (33)


  1. gimbarr_dpr
    11.12.2017 11:02

    Оч интересно. Спасибо.


  1. RomanGL
    11.12.2017 11:22

    Как раз пишу UWP приложение с Fluent-дизайном. Спасибо, пригодится.


  1. ad1Dima
    11.12.2017 13:08

    С клавиатуры ваш контрол теперь тоже не работает (провалиться в него нельзя)…


    1. vklimentiev Автор
      11.12.2017 14:12

      Да, спасибо. Это поправимо.


  1. Error1024
    11.12.2017 13:53

    Честно говоря ожидал что в UWP создать кастомный контрол будет проще и «красивее», сейчас это похоже на кучу костылей и хождение по неопределенному поведению :(


    1. vklimentiev Автор
      11.12.2017 14:11

      Смотря какой. В большинстве случаев действительно не нужно спускаться на такой низкий уровень. Но рассказать эту историю мне показалось интереснее, чем что-то простое и банальное :)


    1. hc4
      11.12.2017 16:35

      Много делаю UI в WPF и могу сказать, что подобное (как в этой статье) приходится делать сплошь и рядом. А всё потому, что весь базовый набор контролов в WPF — это огромный склад костылей. И далеко не всегда есть возможность модифицировать поведение стандартных контролов.


    1. ad1Dima
      11.12.2017 21:22

      Вот про другой контрол: Проще и без неопределённого поведения. events.yandex.ru/lib/talks/5073


  1. synmcj
    11.12.2017 19:35

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


    1. vklimentiev Автор
      11.12.2017 20:49

      Спасибо. И, да, согласен — ещё очень много чего предстоит сделать.


  1. ad1Dima
    11.12.2017 21:19

    Хм, а вы пробовали
    1) в случае гридвью в OnSizeChanged находить ScrollViewer и вызывать у него InvalidateScrollInfo чтоб он дальше все сам делал?
    2) вместо полностью кастомного скрола, просто отнаследоваться от ScrollViewer и переопределить метод OnPointerWheelChanged с пустым телом?


    1. vklimentiev Автор
      11.12.2017 21:52

      1) Не понял мысль. ScrollViewer.InvalidateScrollInfo ведь ничего не знает о том, что внутри него лежит панель, внутри которой изменились размеры элементов. Как он сможет рассчитать новое значение HorizontalOffset?
      2) ScrollViewer в UWP объявлен как sealed. От него нельзя унаследоваться. А так да, это было бы хорошее решение.


      1. ad1Dima
        11.12.2017 22:07

        2) Действительно, проглядел. Обидно.

        1) размеры элементов изменились, они инвалидируют свой размер и уведомляют родительскую панель. Панель тоже пересчитывает свой размер и перерендеривает себя. По каким-то причинам до скролвьювера не доходит информация о том, что изменились снаппойнты иразмер скролящейся области. Идея в том, что если ему сообщить, что данные устарели (а это как раз InvalidateScrollInfo), то он должен опросить панель у которой уже есть новая информация.


        1. vklimentiev Автор
          12.12.2017 12:45

          Попробовал сейчас на примере AdaptiveGridView из UwpToolkit-а. Само по себе — это ни на что не влияет:

          private void OnSizeChanged(object sender, SizeChangedEventArgs e)
          {
              if (e.PreviousSize.Width != e.NewSize.Width)
              {
                  RecalculateLayout(e.NewSize.Width);
              }
              var scrollViewer = this.FindDescendant<ScrollViewer>();
              scrollViewer.InvalidateScrollInfo();
          }

          Ещё попробовал InvalidateScrollInfo использовать в паре с scrollViewer.ChangeView. И снова непредсказуемый результат при максимизации окна.


          1. ad1Dima
            12.12.2017 13:04

            Должен же быть какой-то нормальный способ. Попробую на выходных глянуть…


  1. dmitry_dvm
    12.12.2017 12:22

    Я правильно понимаю, что весь этот Composition надо писать в codebehind и на XAML далеко не уедешь?


    1. vklimentiev Автор
      12.12.2017 13:00

      Исходно, да — вся работа с Composition пишется на C#. Но никто не мешает писать свои Behavior-ы и Attached-свойства и цеплять их через XAML.
      Собственно, ребята из Microsoft именно это уже и делают в UwpToolkit: Blur, Reorder Grid, Параллакс и др. Посмотрите поподробнее, там много всего удобного.

      А когда готовых компонентов из UwpToolkit-а становится мало, тогда начинаешь писать свои уже на C#.


    1. ad1Dima
      12.12.2017 13:01

      Пока да. Я для себя сделал Behavior для некоторых задач.


  1. khamitimur
    12.12.2017 12:45

    целая статья о том как вы не смогли использовать FlipView с одним Behavior для контрола прокрутки.


    1. vklimentiev Автор
      12.12.2017 12:47

      Звучит довольно голословно. Можно поподробнее — какой именно Behavior вы предлагаете сделать для FlipView?


  1. khamitimur
    12.12.2017 13:42

    За исключением отображения крайних элементов FlipView делает ровно то, что вам нужно. Осталось добавить Behavior для анимации переходов между элементами, завязанной на позиции ScrollViewer'а.


    1. vklimentiev Автор
      12.12.2017 13:54

      Так вся соль в этих крайних элементах. Без них такие анимации будут выглядеть ущербно. Поверьте, мы пробовали.
      Плюс проблема с вложенными ScrollViewer-ами никуда не денется с FlipView.


      1. khamitimur
        12.12.2017 14:00

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


      1. khamitimur
        12.12.2017 14:18

        Извините, немного рассеянный сегодня. В общем, «проблема» вложенных ScrollViewer'ов решается очень просто. Достаточно отслеживать тип указателя в ивенте PointerEntered. Мышь — HorizontalScrollMode = Disabled, всё остальное — Enabled. Т.е. сколл работает для всего, кроме мыши. Тогда и ScrollViewer снизу будет колёсико мышки автоматически обрабатывать.


        1. ad1Dima
          12.12.2017 14:44

          кстати, вариант. Надо только аккуратно все возможные PointerLost обработать. Напиши и ты статью )


          1. khamitimur
            12.12.2017 14:56

            Как показала практика, следить ни за чем не надо :)


          1. khamitimur
            12.12.2017 18:10

            Напишу на неделе. И про реализацию контрола из статьи на основе FlipView напишу. Давно хотел что-нибудь полезное написать.


        1. vklimentiev Автор
          12.12.2017 17:15

          А вот за это прям спасибо! Добавил во все остальные наши горизонтальные галереи.


          1. khamitimur
            12.12.2017 17:20

            Не за что.


  1. Real3L0
    13.12.2017 11:50

    >> Центральный элемент как бы слегка приподнимается надо всеми остальными.
    Пока это не прочитал — не видел. Стоило ли оно того?


    1. vklimentiev Автор
      13.12.2017 12:06

      1. Вопрос философский. Я думаю, что из таких мелочей, в том числе, и складывается ощущение действительно хорошего приложения. Посмотрите на приложения под iOS, например — они же все прям классные. А зайдешь в Microsoft Store, там каждое второе приложение — унылое. Вот мы и пытаемся это исправлять по мере сил ;)
      2. И, да, и в самом нашем приложении это выглядит заметнее, чем на gif-ке, т.к. нет такого контрастного белого фона вокруг.


  1. Anarh
    14.12.2017 11:10

    А есть вероятность того, что вы выложите его в опенсорс?


    1. vklimentiev Автор
      14.12.2017 11:22

      Пока не могу ничего обещать…