iviでは、Windows 10(PCおよびタブレット用)向けにアプリケーションを更新することを長い間計画しています。 リラクゼーションのための一種の「居心地の良い」コーナーにしたかったのです。 そのため、最近発表されたMicrosoftの流
fluentなデザインのコンセプトが
役に立ちました。
ただし、ここでは標準コンポーネントについては説明しません。Microsoftには流なデザイン(
Acrylic 、
Reveal 、
Connectedアニメーションなど)を提供していますが、もちろんそれらも使用しています。 すべてがシンプルで明快です-ドキュメントを受け取って使用してください。
しかし、冒険は通常、人里離れたところから始まります。 したがって、1つのカスタムコントロールをどのように実行したかについてお話しした方がよいでしょう。 以下がその1つです。
流Theな設計システムの深さと動きを使用するという考え方です。 中心的な要素は、いわば、他のすべての要素よりもわずかに高くなっています。 これは、スクロール中にサイズと影をアニメーション化することで実現されます。
FlipViewコントロールはすぐには来ませんでした。なぜなら、 彼は、次の要素と前の要素の断片を表示する方法を知りません(「耳」と呼びます)。 そして、解決策を探し始めました。
方法1. GridViewを使用しよう
論理的な解決策は、
GridViewを使用してみることでした。 水平線にアイテムを並べるには、ItemsPanelセットとして設定します。
<ItemsStackPanel Orientation="Horizontal" />
現在の要素を中央に配置するには、GridViewテンプレートのScrollViewerプロパティを使用します。
<ScrollViewer HorizontalSnapPointsType="MandatorySingle" HorizontalSnapPointsAlignment="Center" />
このような実装の例は、たとえば
ここで見ることができ
ます 。
すべては大丈夫のようですが、問題があります。
グリッドビュー 問題1.画面の幅全体にわたる制御のスケーリング
デザイナーが設計したとおり、コントロールはウィンドウの幅全体に広がる必要があります。 これ自体は問題ではありません。 ただし、コントロールのサイズを変更する場合、そのすべての子(アイテム)のサイズも同期的に変更する必要があります。
- 要素の幅は、コントロールの幅の90%に設定する必要があります(「耳」の残り10%)。
- 要素の高さは、画像の幅と比率に基づいて計算する必要があります。
- 同時に、小さな画面幅では、スケーリング後に画像が小さくなりすぎないように、画像を左右にトリミングする必要があります。
すぐに使用できるGridViewはありません。
UWPToolkitのAdaptiveGridViewコントロールの実装で解決策を
見つけました 。
- GridViewを継承し、ItemWidthとItemHeightの2つのプロパティを追加します。
- SizeChangedイベントハンドラーでは、GridViewの幅に応じてこれらのプロパティを計算します。
- GridViewのPrepareContainerForItemOverrideメソッドをオーバーライドします。 ユーザーに表示される前に、各ItemContainerに対して呼び出されます。 そして、作成した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のソースコードを参照してください。
すべてが問題ないように見えます しかし...
グリッドビュー 問題2.アイテムのサイズを変更すると、現在のアイテムが範囲外になる
ただし、GridView内の要素の幅を動的に変更し始めるとすぐに、次の問題に直面します。 この時点で、まったく異なる要素が可視領域に落ち始めます。 これは、GridView内のScrollViewerのHorizontalOffsetが変更されないという事実によるものです。 GridViewは、このようなキャッチを私たちに提案していません。
この効果は、ウィンドウを最大化するときに特に顕著です(サイズが急激に変化するため)。 また、単にHorizontalOffsetの値が大きい場合でも。
この問題は、GridViewに目的の要素までスクロールするように依頼することで解決できるようです。
private async void OnSizeChanged(object sender, SizeChangedEventArgs e) { ... await Task.Yield(); this.ScrollIntoView(getCurrentItem(), ScrollIntoViewAlignment.Default); }
しかし、いいえ:
- Task.Yield()を使用しないと、これは機能しません。 そしてそれで-visualい視覚的痙攣につながる-なぜなら ScrollIntoViewが実行される前に、別の要素が表示されます。
- また、SnapPointsが有効になっている場合、何らかの理由でScrollIntoViewは原則として正しく機能しません。 それらに引っかかっているかのように。
これは、GridViewのサイズを変更するたびにScrollViewerの新しいHorizontalOffset値を手動で計算して設定することでも解決できます。
private void OnSizeChanged(object sender, SizeChangedEventArgs e) { ... var scrollViewer = this.FindDescendant<ScrollViewer>(); scrollViewer.ChangeView(calculateNewHorizontalOffset(...), 0, 1, true); }
ただし、これはウィンドウの段階的なサイズ変更でのみ機能します。 ウィンドウを最大化するとき、これはしばしば間違った結果をもたらします。 おそらく、その理由は、計算した新しいHorizontalOffsetの値が大きすぎてExtentWidth(ScrollViewer内のコンテンツの幅)を超えているためです。 そして以来 GridViewは
UI-virtualizationを使用し、ExtentWidth Item-sの幅を変更した後に自動的に再カウントされない場合があります。
一般に、この問題に対する適切な解決策は見つかりませんでした。
ここで停止して、次の解決策の検索を開始できます。 しかし、このアプローチの別の問題について説明します。
グリッドビュー 問題3.ネストされたScrollViewersは、マウスでの水平スクロールを中断します
マウスホイールが常に垂直にスクロールするようにします。 常に。 垂直に。
ただし、GridViewページを水平スクロールで配置すると、その深さにあるScrollViewerはマウスホイールイベントをキャプチャし、それ以上スキップしません。 その結果、マウスカーソルがコントロールリストの上にある場合、マウスホイールはその中を水平スクロールします。 これは不便であり、ユーザーを混乱させます。
この問題には2つの解決策があります。
タッチスクリーンを失いたくはありませんでした。より良い解決策を探し続けました。
パス2. UWPToolkitからのカルーセル
次のソリューションは、
UWPToolkitからの
カルーセルの制御
でした 。 すべての側面から非常に興味深く、有益なコントロール。 誰でもその実装を勉強することをお勧めします。
彼は私たちのニーズをかなりよくカバーしてくれました。 しかし、最終的には適合しませんでした:
- 幅を変更するときに要素のスケーリングはありません(上記を参照)。
- これは解決可能な問題です。 なぜなら 彼はオープンソースです。 また、スケーリングを追加することは難しくありません。
- また、オープンソースの実装により、スケーリング後に現在の要素をスコープ内に保持する問題も解決されます。
- UI仮想化の欠落:
- カルーセルは、ItemsPanelの独自の実装を使用します。 また、UI仮想化のサポートはありません。
- これは私たちにとって非常に重要なことです。なぜなら リーフレットには非常に多くの販促資料を含めることができ、これはページの読み込み時間に大きく影響します。
- はい、これもおそらく実行可能です。 しかし、それはもはや単純に見えません。
- UIスレッド(ストーリーボードと操作*イベント)でアニメーションを使用します。これは、定義上、常に十分に滑らかではありません。
すなわち 必要に応じてこのコントロールを完成させるのにかなりの時間を費やす必要があることがわかります(1つのUI仮想化の価値があります)。 同時に、出力では、アニメーションが遅くなる可能性のある作品を取得します。
一般的に、このアプローチを放棄することも決定しました。 時間を無駄にするなら、賢明にそれをするでしょう。
方法3.実装
「タッチのみ」ScrollViewerの作成
マウスホイールからすべてのイベントをキャプチャするため、標準のScrollViewerは使用したくないことを思い出させてください(上記の「GridView。問題3」のセクションを参照)。
カルーセルの実装は好きではありません。 UIストリームでアニメーションを使用し、UWPアプリケーション用のアニメーションを作成するための好ましい方法は、
Compositionアニメーションです。 より馴染みのあるストーリーボードとの違いは、別のコンポジションストリームで動作するため、UIストリームが何かでビジーな場合でも60フレーム/秒を提供することです。
タスクを実行するには、アニメーションのソースとしてタッチ入力を使用できるようにするコンポーネントである
InteractionTrackerが必要です。 実際、最初にやらなければならないことは、画面上の指の動きに応じて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%)、理由を見つけることができませんでした。 マイクロソフトのドキュメントと公式の例でこれについて何も言われていないのはなぜですか-私たちは知りません;)
タッチのみScrollViewer。 HorizontalOffsetとイベントViewChangingおよびViewChangedを形成します
ScrollViewerに似ているため、HorizontalOffsetプロパティとViewChangingおよびViewChangedイベントが必要です。 InteractionTrackerのコールバックを処理して実装します。 これらを取得するには、InteractionTrackerを作成するときに、これらのコールバックを受け取るIInteractionTrackerOwnerを実装するオブジェクトを指定する必要があります。
_interactionTrackerOwner = new InteractionTrackerOwner(this); _tracker = InteractionTracker.CreateWithOwner(_compositor, _interactionTrackerOwner);
完全を期すために、InteractionTrackerの状態とイベントを含むドキュメントから写真をコピーします。
ViewChangedイベントは、アイドル状態に入るとスローされます。
IInteractionTrackerOwner.ValuesChangedが発生すると、ViewChangingイベントがスローされます。
InteractionTrackerがアイドル状態のときにValuesChangedが発生する可能性があることをすぐに言わなければなりません。 これは、InteractionTracker TryUpdatePositionを呼び出した後に発生します。 そして、それはUWPプラットフォームのバグのように見えます。
まあ、あなたはそれに我慢しなければなりません。 幸いなことに、それは難しくありません。ValuesChangedに応じて、現在の状態に応じてViewChangingまたはValuesChangedのいずれかを破棄します。
private class InteractionTrackerOwner : IInteractionTrackerOwner { private readonly TouchOnlyScrollerViewer _scrollViewer; public void ValuesChanged(InteractionTracker sender, InteractionTrackerValuesChangedArgs args) {
タッチのみScrollViewer。 ポイントをスナップして正確に1つの要素をスクロール
1つの要素で正確にスクロールするために、すばらしい解決策があります-「
慣性修正子を使用したスナップポイント 」。
ポイントは、タッチスクリーンをスワイプした後にスクロールを停止するポイントを設定することです。 そして、残りのロジックはInteractionTrackerによって取得されます。 実際、スワイプ後の停止がスムーズに、同時に必要な場所で正確に発生するように、減速率を変更します。
私たちの実装は、
ドキュメントの例で説明されている実装とわずかに異なり
ます 。 ユーザーがリーフレットをあまりにも速く「ねじった」場合でも、一度に複数の要素をスクロールさせたくないためです。
したがって、「左へ1ステップ」、「右へ1ステップ」、「現在の位置にとどまる」という3つのスナップポイントのみを追加します。 そして、各スクロールの後にそれらを更新します。
また、スクロール後に毎回スナップポイントを再作成しないように、それらをパラメーター化可能にします。 これを行うには、3つのプロパティで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は、スナップポイントがない場合に慣性が終了するオフセットです。
- SnapPoint.RestingValue-SnapPoint.Condition条件が満たされたときに停止が許可されるオフセット。
最初に、スナップポイント間の真ん中の条件に境界線を設定しようとしましたが、ユーザーは何らかの理由ですべてのスワイプが次の要素にスクロールするわけではないことに気付きました。 一部のスワイプは十分に速くなく、ロールバックがありました。
したがって、Contitionの式では、0.25および0.75の係数を使用しているため、「遅い」スワイプでも次の要素にスクロールします。
次の要素にスクロールするたびに、このメソッドを呼び出してスナップポイントパラメーターを更新します。
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を探すことが示唆されました。 そして、どういうわけかこれをオーバーライドして、標準のScrollViewerの代わりにそれが私たちのものになるように、見つけられません。
じゃあ したがって、UI仮想化を使用したパネルは、独立して実行する必要があります。 このトピックで見つかった最高のものは、11年前という早くもこのシリーズの記事でした。1、2、3、4です。 確かに、それはWPFについてであり、UWPについてではありませんが、アイデアを非常によく伝えています。 私たちはそれを利用しました。
実際には、アイデアは簡単です。
- このようなパネルはTouchOnlyScrollerViewer内に埋め込まれ、ViewChangingおよびViewChangedイベントにサブスクライブします。
- パネルは、限られた数の子UI要素を作成します。 私たちの場合、これは5(中央に1つ、左右から突き出た「耳」に2つ、「耳」に続く要素のキャッシュに2つ)です。
- UI要素はTouchOnlyScrollerViewer.HorizontalOffsetに応じて配置され、スクロールされるときに目的のデータオブジェクトに再アタッチされます。
実装を示しません。なぜなら かなり複雑でした。 むしろ、別の記事のトピックです。
タッチ入力をコンポジションストリームにリダイレクトした後に失われたタップイベントを探しています
まとめると、別の興味深い問題が明らかになりました。 タッチ入力がInteractionTrackerにリダイレクトされている間に、ユーザーがコントロール内の要素をタップする場合があります。 これは、慣性スクロールが発生したときに発生します。 この場合、PointerPressed、PointerReleased、およびTappedイベントは発生しません。 そして、これは大げさな問題ではありません。 InteractionTrackerの慣性はかなり長いです。 また、視覚的なスクロールがほぼ終了した場合でも、実際には、最後の数ピクセルの遅いスクロールが発生する場合があります。
その結果、ユーザーは動揺しています-彼は、選択した映画のページがタップで開くことを期待しています。 しかし、これは起こりません。
したがって、InteractionTrackerからのイベントのペアによってタップを識別します。
- 対話状態に入る(指が画面に触れた);
- その後、直ちに(150ミリ秒未満で)慣性状態に移行します(指で画面を解放しました)。 同時に、スクロール速度はゼロでなければなりません(そうでなければ、タップではなくスワイプになります)。
public void InertiaStateEntered(InteractionTracker sender, InteractionTrackerInertiaStateEnteredArgs args) { if (_interactionTrackerState == InteractionTrackerState.Interacting && (DateTime.Now - _lastStateTime) < TimeSpan.FromMilliseconds(150) && Math.Abs(args.PositionVelocityInPixelsPerSecond.X) < 1 ) { _scrollViewer.TappedCustom?.Invoke(_scrollViewer.HorizontalOffset); } _interactionTrackerState = InteractionTrackerState.Inertia; _lastStateTime = DateTime.Now; }
動作します。 ただし、タップが実装された要素を認識することはできません。 私たちの場合、これは重要ではありません。 要素は、TouchOnlyScrollViewerの表示幅のほぼ全体を占めます。 したがって、単純に中心に近いものを選択します。 ほとんどの場合、これはまさにあなたが必要とするものです。 これまでのところ、スクロール中に時々タップすると間違った場所につながることに気づいてさえいません。 あなたがそれを知っていても、キャッチするのはそれほど簡単ではありません;)
一般的なケースではありますが、これは完全なソリューションではありません。 完全に実装するには、ヒットテストをヒットする必要があります。 しかし、それを行う方法は明確ではありません。 タップの座標は不明です...
ボーナス 不透明度、スケール、影の表現アニメーション。 最終的に美しくなるために
そして最後に、ケーキのチェリーはそれがすべてであったものです。 スクロールしながら、要素のサイズ、影、透明度を変更します。 中心にあるものがわずかに盛り上がっているような感覚を作り出すため。
このために、
Expression-animationsを使用します。 これらはコンポジションサブシステムの一部でもあり、別のスレッドで動作するため、UIスレッドがビジーのときに速度が低下することはありません。
これらはこのように作成されます。 プロパティをアニメーション化するために、このプロパティが他のプロパティに依存することを定義する式を定義します。 式はテキスト文字列として与えられます。
彼らの魅力は、チェーンで配置できることです。 これを使用します。
すべてのアニメーションのソースは、InteractionTrackerからのピクセル単位のオフセットです。 UI- progress, 0 1. progress- .
, _progressExpression , :
_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 :)
→ , ,
.