【Win10】實現 ListViewBase 平滑滾動
首先解釋下標題的 ListViewBase 是什麼鬼。ListViewBase 我們可以查閱 MSDN 文件:https://msdn.microsoft.com/zh-cn/library/windows.ui.xaml.controls.listviewbase.aspx 得知,ListViewBase 是 ListView 和 GridView 的基類(ListView 和 GridView 則為常用的資料展示控制元件之一)。而本文的主要目的就是實現 ListView 和 GridView 的平滑滾動,因此我將標題寫成“實現 ListViewBase 平滑滾動”而不是“實現 ListView 和 GridView 平滑滾動”(實際上本文適用於任何繼承自 ListViewBase 的控制元件)。
首先我們先複習一下怎麼滾動到 ListViewBase 的某一個 item。
在 ListViewBase 類中,有一個方法叫做 ScrollIntoView。這個方法有兩個過載,我們看複雜一點,有兩個引數的這個:
// // 摘要: // 滾動列表,以將指定資料項移入具有指定對齊方式的檢視中。 // // 引數: // item: // 要在檢視中顯示的資料項。 // // alignment: // 指定項是使用 Default 還是 Leading 對齊方式的列舉值。 [Overload("ScrollIntoViewWithAlignment")] public void ScrollIntoView(System.Object item, ScrollIntoViewAlignment alignment);
第一個引數就是我們需要滾動到當前可視區域的 item,而第二個引數,Default 是指讓其滾動到當前可視區域即可,Leading 則是指讓其滾動到當前可視區域的頂部。
但是比較遺憾的是,這個方法一執行就(?)立馬滾動到目標 item 了,完全不帶一丁點動畫效果(後文你會了解到內部執行仍需很少一段時間,儘管我們肉眼察覺不到)。在這個時代,沒有一個好的 UI,怎麼能吸引使用者呢?因此我們就來研究並實現怎樣能讓 ListViewBase 平滑滾動到某個 item。
說起滾動的話,我們一定會想到 ScrollBar、ScrollViewer 這類的控制元件的。而幸運的是,ScrollViewer 有一個方法,叫 ChangeView 是帶動畫效果的(也可以選擇不使用動畫效果)。並且 ListView、GridView 內部都是有一個 ScrollViewer 的。那麼我們自然而然就想到,是不是可以操作 ListViewBase 內部的這個 ScrollViewer 來實現平滑滾動。
先開始編寫程式碼吧:
public static class ListViewBaseExtensions { public static void ScrollIntoViewSmoothly(this ListViewBase listViewBase, object item, ScrollIntoViewAlignment alignment) { if (listViewBase == null) { throw new ArgumentNullException(nameof(listViewBase)); } // GetFirstDescendantOfType 是 WinRTXamlToolkit 中的擴充套件方法, // 尋找該控制元件在可視樹上第一個符合型別的子元素。 ScrollViewer scrollViewer = listViewBase.GetFirstDescendantOfType<ScrollViewer>(); // 由於 ScrollViewer 肯定有,因此不做 null 檢查判斷了。 scrollViewer.ChangeView(targetHorizontalOffset, targetVerticalOffset, null); } }
然而問題來了,targetHorizontalOffset 和 targetVerticalOffset 我們是不知道的,也就是說,我們不知道目標 item 所在的位置。
儘管我們不知道,但是,ListViewBase 自身的 ScrollIntoView 方法它是知道的,那我們乾脆就讓它當個跑腿,先執行一次,然後就可以獲取目標位置了。
public static class ListViewBaseExtensions { public static void ScrollIntoViewSmoothly(this ListViewBase listViewBase, object item, ScrollIntoViewAlignment alignment) { if (listViewBase == null) { throw new ArgumentNullException(nameof(listViewBase)); } // GetFirstDescendantOfType 是 WinRTXamlToolkit 中的擴充套件方法, // 尋找該控制元件在可視樹上第一個符合型別的子元素。 ScrollViewer scrollViewer = listViewBase.GetFirstDescendantOfType<ScrollViewer>(); // 由於 ScrollViewer 肯定有,因此不做 null 檢查判斷了。 // 記錄初始位置,用於 ScrollIntoView 檢測目標位置後復原。 double originHorizontalOffset = scrollViewer.HorizontalOffset; double originVerticalOffset = scrollViewer.VerticalOffset; // 跑腿。 listViewBase.ScrollIntoView(item, alignment); // 獲取目標位置。 double targetHorizontalOffset = scrollViewer.HorizontalOffset; double targetVerticalOffset = scrollViewer.VerticalOffset; // scrollViewer.ChangeView(targetHorizontalOffset, targetVerticalOffset, null); } }
然而通過斷點檢查後,發現 targetHorizontalOffset 和 targetVerticalOffset 並沒有發生變化。但是執行過後,ListViewBase 確實發生了滾動,因此我們質疑,是不是 ScrollIntoView 方法在控制元件內部是以一個非同步的形式執行。
這個時候,我們還是想起近乎萬能的 LayoutUpdated 事件吧。改寫下程式碼。
public static class ListViewBaseExtensions { public static void ScrollIntoViewSmoothly(this ListViewBase listViewBase, object item, ScrollIntoViewAlignment alignment) { if (listViewBase == null) { throw new ArgumentNullException(nameof(listViewBase)); } // GetFirstDescendantOfType 是 WinRTXamlToolkit 中的擴充套件方法, // 尋找該控制元件在可視樹上第一個符合型別的子元素。 ScrollViewer scrollViewer = listViewBase.GetFirstDescendantOfType<ScrollViewer>(); // 由於 ScrollViewer 肯定有,因此不做 null 檢查判斷了。 // 記錄初始位置,用於 ScrollIntoView 檢測目標位置後復原。 double originHorizontalOffset = scrollViewer.HorizontalOffset; double originVerticalOffset = scrollViewer.VerticalOffset; EventHandler<object> layoutUpdatedHandler = null; layoutUpdatedHandler = delegate { listViewBase.LayoutUpdated -= layoutUpdatedHandler; // 獲取目標位置。 double targetHorizontalOffset = scrollViewer.HorizontalOffset; double targetVerticalOffset = scrollViewer.VerticalOffset; // scrollViewer.ChangeView(targetHorizontalOffset, targetVerticalOffset, null); }; listViewBase.LayoutUpdated += layoutUpdatedHandler; // 跑腿。 listViewBase.ScrollIntoView(item, alignment); } }
這次我們再斷點後,發現能夠獲取目標位置了!!(所以我上面說“內部執行仍需很少一段時間,儘管我們肉眼察覺不到”)
接下來,由於跑腿是已經滾動目標位置了,因此我們需要復原到原來的位置,再滾動到目標位置以實現平滑滾動的動畫效果。
public static void ScrollIntoViewSmoothly(this ListViewBase listViewBase, object item, ScrollIntoViewAlignment alignment) { if (listViewBase == null) { throw new ArgumentNullException(nameof(listViewBase)); } // GetFirstDescendantOfType 是 WinRTXamlToolkit 中的擴充套件方法, // 尋找該控制元件在可視樹上第一個符合型別的子元素。 ScrollViewer scrollViewer = listViewBase.GetFirstDescendantOfType<ScrollViewer>(); // 由於 ScrollViewer 肯定有,因此不做 null 檢查判斷了。 // 記錄初始位置,用於 ScrollIntoView 檢測目標位置後復原。 double originHorizontalOffset = scrollViewer.HorizontalOffset; double originVerticalOffset = scrollViewer.VerticalOffset; EventHandler<object> layoutUpdatedHandler = null; layoutUpdatedHandler = delegate { listViewBase.LayoutUpdated -= layoutUpdatedHandler; // 獲取目標位置。 double targetHorizontalOffset = scrollViewer.HorizontalOffset; double targetVerticalOffset = scrollViewer.VerticalOffset; // 復原位置,且不需要使用動畫效果。 scrollViewer.ChangeView(originHorizontalOffset, originVerticalOffset, null, true); // 最終目的,帶平滑滾動效果滾動到 item。 scrollViewer.ChangeView(targetHorizontalOffset, targetVerticalOffset, null); }; listViewBase.LayoutUpdated += layoutUpdatedHandler; // 跑腿。 listViewBase.ScrollIntoView(item, alignment); } }
執行之後,然而我們發現還是直接滾動到目標,不帶一丁點動畫效果。但是,有了上面 ScrollIntoView 的經驗後,我們自然而然也可以質疑 ChangeView 方法是不是像 ScrollIntoView 一樣,內部也是非同步執行的。再改寫下:
public static class ListViewBaseExtensions { public static void ScrollIntoViewSmoothly(this ListViewBase listViewBase, object item, ScrollIntoViewAlignment alignment) { if (listViewBase == null) { throw new ArgumentNullException(nameof(listViewBase)); } // GetFirstDescendantOfType 是 WinRTXamlToolkit 中的擴充套件方法, // 尋找該控制元件在可視樹上第一個符合型別的子元素。 ScrollViewer scrollViewer = listViewBase.GetFirstDescendantOfType<ScrollViewer>(); // 由於 ScrollViewer 肯定有,因此不做 null 檢查判斷了。 // 記錄初始位置,用於 ScrollIntoView 檢測目標位置後復原。 double originHorizontalOffset = scrollViewer.HorizontalOffset; double originVerticalOffset = scrollViewer.VerticalOffset; EventHandler<object> layoutUpdatedHandler = null; layoutUpdatedHandler = delegate { listViewBase.LayoutUpdated -= layoutUpdatedHandler; // 獲取目標位置。 double targetHorizontalOffset = scrollViewer.HorizontalOffset; double targetVerticalOffset = scrollViewer.VerticalOffset; EventHandler<ScrollViewerViewChangedEventArgs> scrollHandler = null; scrollHandler = delegate { scrollViewer.ViewChanged -= scrollHandler; // 最終目的,帶平滑滾動效果滾動到 item。 scrollViewer.ChangeView(targetHorizontalOffset, targetVerticalOffset, null); }; scrollViewer.ViewChanged += scrollHandler; // 復原位置,且不需要使用動畫效果。 scrollViewer.ChangeView(originHorizontalOffset, originVerticalOffset, null, true); }; listViewBase.LayoutUpdated += layoutUpdatedHandler; // 跑腿。 listViewBase.ScrollIntoView(item, alignment); } }
這次我們終於成功了!!!
效果:
最後我們像 ListViewBase 的 ScrollIntoView 方法,加多個只有一個引數的過載吧。
最終程式碼:
public static class ListViewBaseExtensions { public static void ScrollIntoViewSmoothly(this ListViewBase listViewBase, object item) { ScrollIntoViewSmoothly(listViewBase, item, ScrollIntoViewAlignment.Default); } public static void ScrollIntoViewSmoothly(this ListViewBase listViewBase, object item, ScrollIntoViewAlignment alignment) { if (listViewBase == null) { throw new ArgumentNullException(nameof(listViewBase)); } // GetFirstDescendantOfType 是 WinRTXamlToolkit 中的擴充套件方法, // 尋找該控制元件在可視樹上第一個符合型別的子元素。 ScrollViewer scrollViewer = listViewBase.GetFirstDescendantOfType<ScrollViewer>(); // 由於 ScrollViewer 肯定有,因此不做 null 檢查判斷了。 // 記錄初始位置,用於 ScrollIntoView 檢測目標位置後復原。 double originHorizontalOffset = scrollViewer.HorizontalOffset; double originVerticalOffset = scrollViewer.VerticalOffset; EventHandler<object> layoutUpdatedHandler = null; layoutUpdatedHandler = delegate { listViewBase.LayoutUpdated -= layoutUpdatedHandler; // 獲取目標位置。 double targetHorizontalOffset = scrollViewer.HorizontalOffset; double targetVerticalOffset = scrollViewer.VerticalOffset; EventHandler<ScrollViewerViewChangedEventArgs> scrollHandler = null; scrollHandler = delegate { scrollViewer.ViewChanged -= scrollHandler; // 最終目的,帶平滑滾動效果滾動到 item。 scrollViewer.ChangeView(targetHorizontalOffset, targetVerticalOffset, null); }; scrollViewer.ViewChanged += scrollHandler; // 復原位置,且不需要使用動畫效果。 scrollViewer.ChangeView(originHorizontalOffset, originVerticalOffset, null, true); }; listViewBase.LayoutUpdated += layoutUpdatedHandler; // 跑腿。 listViewBase.ScrollIntoView(item, alignment); } }
最後再附送上 Demo:ListViewBaseScrollSmoothlyDemo.zip