Skip to content

Commit

Permalink
Added new Setting behavior to center list views automatically (#628)
Browse files Browse the repository at this point in the history
This is a new option in the app settings that is off by default. It is an accessibility setting and also makes it easier to interact with the List Views across the app. Works with touch, mouse and keyboard.

It is added under a new settings category called "Behavior". More customizations will be added under this category in the future. This option, just like others, is remembered between app sessons so you only need to configure it once.
  • Loading branch information
HotCakeX authored Feb 28, 2025
1 parent ea21208 commit e8469c1
Show file tree
Hide file tree
Showing 24 changed files with 461 additions and 5 deletions.
3 changes: 2 additions & 1 deletion AppControl Manager/AppSettings/AppSettingsCls.cs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ internal enum SettingKeys
MainWindowHeight,
MainWindowIsMaximized,
AutomaticAssignmentSidebar,
AutoCheckForUpdateAtStartup
AutoCheckForUpdateAtStartup,
ListViewsVerticalCentering
}
}
220 changes: 219 additions & 1 deletion AppControl Manager/Others/ListViewUIHelpers.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
using System.Text;
using System.Collections;
using System.Collections.Generic;
using System.Text;
using System.Threading.Tasks;
using AppControlManager.IntelGathering;
using CommunityToolkit.WinUI;
using Microsoft.UI.Text;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Controls.Primitives;
using Microsoft.UI.Xaml.Media;
using Windows.Foundation;
using static AppControlManager.AppSettings.AppSettingsCls;

namespace AppControlManager.Others;

/// <summary>
/// This class includes methods that are helpers for the custom ListView implementations in this application.
/// </summary>
internal static class ListViewUIHelpers
{
// An offscreen TextBlock for measurement
Expand Down Expand Up @@ -64,4 +74,212 @@ internal static string ConvertRowToText(FileIdentity row)
.AppendLine($"Opus Data: {row.Opus}")
.ToString();
}


/*
Windows Community Toolkit
Copyright © .NET Foundation and Contributors
All rights reserved.
MIT License (MIT)
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/

// This is a modification of the methods in Windows Community Toolkit, ListViewExtensions, Smooth Scroll Into View feature that only has the center vertically code plus some additional logic
// https://github.com/CommunityToolkit/Windows/pull/648


private static readonly Dictionary<ListView, int> ObjRemovalTracking = [];

/// <summary>
/// Smooth scrolling the list to bring the specified index into view, centering vertically
/// </summary>
/// <param name="listViewBase">List to scroll</param>
/// <param name="index">The index to bring into view. Index can be negative.</param>
/// <param name="disableAnimation">Set true to disable animation</param>
/// <param name="scrollIfVisible">Set false to disable scrolling when the corresponding item is in view</param>
/// <param name="additionalHorizontalOffset">Adds additional horizontal offset</param>
/// <param name="additionalVerticalOffset">Adds additional vertical offset</param>
/// <returns>Returns <see cref="Task"/> that completes after scrolling</returns>
internal static async Task SmoothScrollIntoViewWithIndexCenterVerticallyOnlyAsync(this ListViewBase listViewBase, ListView listView, int index, bool disableAnimation = false, bool scrollIfVisible = true, int additionalHorizontalOffset = 0, int additionalVerticalOffset = 0)
{

// Only perform the scroll if the setting is enabled
if (!GetSetting<bool>(SettingKeys.ListViewsVerticalCentering))
{
return;
}

// Don't center if an item was deleted
// Without this step, after row deletion in ListView, the data jumps up/down in a weird way
if (!ObjRemovalTracking.TryGetValue(listView, out int value))
{
ObjRemovalTracking.Add(listView, ((IList)listView.ItemsSource).Count);
}

if (value != ((IList)listView.ItemsSource).Count)
{
ObjRemovalTracking[listView] = ((IList)listView.ItemsSource).Count;

return;
}

if (index > (listViewBase.Items.Count - 1))
{
index = listViewBase.Items.Count - 1;
}

if (index < -listViewBase.Items.Count)
{
index = -listViewBase.Items.Count;
}

index = (index < 0) ? (index + listViewBase.Items.Count) : index;

bool isVirtualizing = default;
double previousXOffset = default, previousYOffset = default;

ScrollViewer? scrollViewer = listViewBase.FindDescendant<ScrollViewer>();
SelectorItem? selectorItem = listViewBase.ContainerFromIndex(index) as SelectorItem;

if (scrollViewer is null)
{
return;
}

// If selectorItem is null then the panel is virtualized.
// So in order to get the container of the item we need to scroll to that item first and then use ContainerFromIndex
if (selectorItem is null)
{
isVirtualizing = true;

previousXOffset = scrollViewer.HorizontalOffset;
previousYOffset = scrollViewer.VerticalOffset;

TaskCompletionSource<object?> tcs = new();

void ViewChanged(object? _, ScrollViewerViewChangedEventArgs __) => tcs.TrySetResult(result: default);

try
{
scrollViewer.ViewChanged += ViewChanged;
listViewBase.ScrollIntoView(listViewBase.Items[index], ScrollIntoViewAlignment.Leading);
_ = await tcs.Task;
}
finally
{
scrollViewer.ViewChanged -= ViewChanged;
}

selectorItem = (SelectorItem)listViewBase.ContainerFromIndex(index);
}

GeneralTransform transform = selectorItem.TransformToVisual((UIElement)scrollViewer.Content);
Point position = transform.TransformPoint(new Point(0, 0));

// Scrolling back to previous position
if (isVirtualizing)
{
await scrollViewer.ChangeViewAsync(previousXOffset, previousYOffset, zoomFactor: null, disableAnimation: true);
}

double listViewBaseWidth = listViewBase.ActualWidth;
double selectorItemWidth = selectorItem.ActualWidth;
double listViewBaseHeight = listViewBase.ActualHeight;
double selectorItemHeight = selectorItem.ActualHeight;

previousXOffset = scrollViewer.HorizontalOffset;
previousYOffset = scrollViewer.VerticalOffset;

double minXPosition = position.X - listViewBaseWidth + selectorItemWidth;
double minYPosition = position.Y - listViewBaseHeight + selectorItemHeight;

double maxXPosition = position.X;
double maxYPosition = position.Y;

double finalXPosition, finalYPosition;

// If the Item is in view and scrollIfVisible is false then we don't need to scroll
if (!scrollIfVisible && (previousXOffset <= maxXPosition && previousXOffset >= minXPosition) && (previousYOffset <= maxYPosition && previousYOffset >= minYPosition))
{
finalXPosition = previousXOffset;
finalYPosition = previousYOffset;
}
// Center it vertically
else
{
finalXPosition = previousXOffset + additionalHorizontalOffset;
finalYPosition = maxYPosition - ((listViewBaseHeight - selectorItemHeight) / 2.0) + additionalVerticalOffset;
}

await scrollViewer.ChangeViewAsync(finalXPosition, finalYPosition, zoomFactor: null, disableAnimation);
}

/// <summary>
/// Changes the view of <see cref="ScrollViewer"/> asynchronous.
/// </summary>
/// <param name="scrollViewer">The scroll viewer.</param>
/// <param name="horizontalOffset">The horizontal offset.</param>
/// <param name="verticalOffset">The vertical offset.</param>
/// <param name="zoomFactor">The zoom factor.</param>
/// <param name="disableAnimation">if set to <c>true</c> disable animation.</param>
private static async Task ChangeViewAsync(this ScrollViewer scrollViewer, double? horizontalOffset, double? verticalOffset, float? zoomFactor, bool disableAnimation)
{
if (horizontalOffset > scrollViewer.ScrollableWidth)
{
horizontalOffset = scrollViewer.ScrollableWidth;
}
else if (horizontalOffset < 0)
{
horizontalOffset = 0;
}

if (verticalOffset > scrollViewer.ScrollableHeight)
{
verticalOffset = scrollViewer.ScrollableHeight;
}
else if (verticalOffset < 0)
{
verticalOffset = 0;
}

// MUST check this and return immediately, otherwise this async task will never complete because ViewChanged event won't get triggered
if (horizontalOffset == scrollViewer.HorizontalOffset && verticalOffset == scrollViewer.VerticalOffset)
{
return;
}

TaskCompletionSource<object?> tcs = new();

void ViewChanged(object? _, ScrollViewerViewChangedEventArgs e)
{
if (e.IsIntermediate)
{
return;
}

_ = tcs.TrySetResult(result: default);
}

try
{
scrollViewer.ViewChanged += ViewChanged;
_ = scrollViewer.ChangeView(horizontalOffset, verticalOffset, zoomFactor, disableAnimation);
_ = await tcs.Task;
}
finally
{
scrollViewer.ViewChanged -= ViewChanged;
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@
ScrollViewer.HorizontalScrollBarVisibility="Visible"
ShowsScrollingPlaceholders="True"
ScrollViewer.VerticalScrollBarVisibility="Visible"
SelectionChanged="FileIdentitiesListView_SelectionChanged"
ContainerContentChanging="ListView_ContainerContentChanging">

<ListView.Header>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,10 @@ private void ListViewItem_RightTapped(object sender, RightTappedRoutedEventArgs
// If the item is not already selected, clear previous selections and select this one.
if (!item.IsSelected)
{

// Set the counter so that the SelectionChanged event handler will ignore the next 2 events.
_skipSelectionChangedCount = 2;

//clear for exclusive selection
FileIdentitiesListView.SelectedItems.Clear();
item.IsSelected = true;
Expand All @@ -751,4 +755,20 @@ private void CtrlC_Invoked(KeyboardAccelerator sender, KeyboardAcceleratorInvoke
ListViewFlyoutMenuCopy_Click(sender, new RoutedEventArgs());
args.Handled = true;
}

// A counter to prevent SelectionChanged event from firing twice when right-clicking on an unselected row
private int _skipSelectionChangedCount;

private async void FileIdentitiesListView_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
// Check if we need to skip this event.
if (_skipSelectionChangedCount > 0)
{
_skipSelectionChangedCount--;
return;
}

await ListViewUIHelpers.SmoothScrollIntoViewWithIndexCenterVerticallyOnlyAsync(listViewBase: (ListView)sender, listView: (ListView)sender, index: ((ListView)sender).SelectedIndex, disableAnimation: false, scrollIfVisible: true, additionalHorizontalOffset: 0, additionalVerticalOffset: 0);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@
ScrollViewer.HorizontalScrollBarVisibility="Visible"
ShowsScrollingPlaceholders="True"
ScrollViewer.VerticalScrollBarVisibility="Visible"
SelectionChanged="FileIdentitiesListView_SelectionChanged"
ContainerContentChanging="ListView_ContainerContentChanging">

<ListView.Header>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -673,6 +673,9 @@ private void ListViewItem_RightTapped(object sender, RightTappedRoutedEventArgs
// If the item is not already selected, clear previous selections and select this one.
if (!item.IsSelected)
{
// Set the counter so that the SelectionChanged event handler will ignore the next 2 events.
_skipSelectionChangedCount = 2;

//clear for exclusive selection
FileIdentitiesListView.SelectedItems.Clear();
item.IsSelected = true;
Expand All @@ -693,4 +696,19 @@ private void CtrlC_Invoked(KeyboardAccelerator sender, KeyboardAcceleratorInvoke
ListViewFlyoutMenuCopy_Click(sender, new RoutedEventArgs());
args.Handled = true;
}

// A counter to prevent SelectionChanged event from firing twice when right-clicking on an unselected row
private int _skipSelectionChangedCount;

private async void FileIdentitiesListView_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
// Check if we need to skip this event.
if (_skipSelectionChangedCount > 0)
{
_skipSelectionChangedCount--;
return;
}

await ListViewUIHelpers.SmoothScrollIntoViewWithIndexCenterVerticallyOnlyAsync(listViewBase: (ListView)sender, listView: (ListView)sender, index: ((ListView)sender).SelectedIndex, disableAnimation: false, scrollIfVisible: true, additionalHorizontalOffset: 0, additionalVerticalOffset: 0);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@
ScrollViewer.HorizontalScrollBarVisibility="Visible"
ShowsScrollingPlaceholders="True"
ScrollViewer.VerticalScrollBarVisibility="Visible"
SelectionChanged="FileIdentitiesListView_SelectionChanged"
ContainerContentChanging="ListView_ContainerContentChanging">

<ListView.Header>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -624,6 +624,9 @@ private void ListViewItem_RightTapped(object sender, RightTappedRoutedEventArgs
// If the item is not already selected, clear previous selections and select this one.
if (!item.IsSelected)
{
// Set the counter so that the SelectionChanged event handler will ignore the next 2 events.
_skipSelectionChangedCount = 2;

//clear for exclusive selection
FileIdentitiesListView.SelectedItems.Clear();
item.IsSelected = true;
Expand All @@ -643,4 +646,19 @@ private void CtrlC_Invoked(KeyboardAccelerator sender, KeyboardAcceleratorInvoke
ListViewFlyoutMenuCopy_Click(sender, new RoutedEventArgs());
args.Handled = true;
}

// A counter to prevent SelectionChanged event from firing twice when right-clicking on an unselected row
private int _skipSelectionChangedCount;

private async void FileIdentitiesListView_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
// Check if we need to skip this event.
if (_skipSelectionChangedCount > 0)
{
_skipSelectionChangedCount--;
return;
}

await ListViewUIHelpers.SmoothScrollIntoViewWithIndexCenterVerticallyOnlyAsync(listViewBase: (ListView)sender, listView: (ListView)sender, index: ((ListView)sender).SelectedIndex, disableAnimation: false, scrollIfVisible: true, additionalHorizontalOffset: 0, additionalVerticalOffset: 0);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@
ScrollViewer.HorizontalScrollBarVisibility="Visible"
ShowsScrollingPlaceholders="True"
ScrollViewer.VerticalScrollBarVisibility="Visible"
SelectionChanged="FileIdentitiesListView_SelectionChanged"
ContainerContentChanging="ListView_ContainerContentChanging">

<ListView.Header>
Expand Down
Loading

0 comments on commit e8469c1

Please sign in to comment.