-
-
Notifications
You must be signed in to change notification settings - Fork 44
Description
Is your feature request related to a problem? Please describe.
Updating the ObservableCollection backing the TableView causes the user to loose their selection. Users complain, they loose track of their work because of this when new items are replaced incrementally. To clarify this issue only occurs when replacing items and the user is situated on one:
User clicks the row for the product name "M9 Flathat"
User clicks the quantity text cell
User inputs clear
User inputs 4000
User inputs tab
User clicks the save button
Wait 2000ms
Expect the selected row with the product name "M9 Flathat"
During the wait time:
- the change is commited to the database
- the changed record is fetched again
- the changed record is replaced in the tableview
Currently the TableView resets the selection, instead of staying on the record.
Describe the solution you'd like
When the collection is updated, keep track of the selected item using a key and restore the selection if the key is the same.
Restoring must be debounced from the change, because changes arrive in batches.
Given the following delta the scroll position and selection tracks successfully, as long as the user is positioned on a Keep row such as index 19:
Insert,"[0,0]"
Keep,"[1,1]"
...
Keep,"[3,3]"
Delete,"[4,3]"
...
Delete,"[18,3]"
Keep,"[19,4]"
...
Keep,"[21,6]"
Delete,"[22,6]""
...
Delete,"[30,6]"
Keep,"[31,7]"
...
Keep,"[38,14]"
-# The format is read as Action, "[ObservableCollectionIndex, SyncSourceIndex]" this is output generated from the minimum edit distance algorithm
If the user is however situated on a replace row, such as index 6, the datagrid looses the selected index as well as the scroll position.
Keep,"[1,1]"
...
Keep,"[3,3]"
Replace,"[4,4]"
Replace,"[5,5]"
Replace,"[6,6]"
Keep,"[7,7]"
...
Keep,"[14,14]"
Describe alternatives you've considered
My current workaround is forcing users into clicking the row they're working on, so that I can scroll it into view. This is suboptimal because:
- Sometimes users don't need to edit the row, they dont click it and loose progress.
- Scrolling to the selected row looses granularity in the scroll position. The row might be at the top of the screen for the user, but is restored to the bottom of the screen.
- The workaround is only applicable to
SelectionMode="Single".
<tv:TableView
SelectionMode="Single"
SelectionUnit="Row">
<i:Interaction.Behaviors>
<behaviors:TableViewHelper/>
</i:Interaction.Behaviors>
</tv:TableView>public sealed class TableViewHelper : Behavior<TableView>
{
private readonly EventHandler<object> _collectionViewOnCurrentChanged;
private readonly VectorChangedEventHandler<object> _collectionViewOnVectorChanged;
private readonly DebouncedAction _restoreCurrentPositionAfterUpdate;
private int? _collectionViewCurrentPosition;
public TableViewHelper()
{
_collectionViewOnCurrentChanged = CollectionViewOnCurrentChanged;
_collectionViewOnVectorChanged = CollectionViewOnVectorChanged;
_restoreCurrentPositionAfterUpdate = new(this,
static (self, arg, ct) => WindowManager.Dispatch((t, ct) => t.self.RestoreCurrentPositionAfterUpdate(t.arg, ct),
(self: (TableViewHelper)self, arg: (ICollectionView)arg!), ct),
TimeSpan.FromMilliseconds(50)
);
}
private void CollectionViewOnCurrentChanged(object? sender, object e)
{
var view = (ICollectionView)sender!;
StoreCurrentPosition(view);
}
private void StoreCurrentPosition(ICollectionView view)
{
if (view.CurrentPosition is >= 0 and var pos && pos != _collectionViewCurrentPosition)
{
_collectionViewCurrentPosition = pos;
}
}
private void CollectionViewOnVectorChanged(IObservableVector<object> sender, IVectorChangedEventArgs @event)
{
var view = (ICollectionView)sender!;
_restoreCurrentPositionAfterUpdate.Invoke(view);
}
private ValueTask RestoreCurrentPositionAfterUpdate(ICollectionView view, CancellationToken ct)
{
// attempt to restore the selected index
if (_collectionViewCurrentPosition is >= 0 and var pos && view.Count > pos && view.CurrentPosition != pos)
{
view.MoveCurrentToPosition(pos);
return new(AssociatedObject.ScrollRowIntoView(pos));
}
return default;
}
protected override void OnAttached()
{
base.OnAttached();
AssociatedObject.CollectionView.CurrentChanged += _collectionViewOnCurrentChanged;
AssociatedObject.CollectionView.VectorChanged += _collectionViewOnVectorChanged;
}
protected override void OnDetaching()
{
AssociatedObject.CollectionView.CurrentChanged -= _collectionViewOnCurrentChanged;
AssociatedObject.CollectionView.VectorChanged -= _collectionViewOnVectorChanged;
base.OnDetaching();
}
}
public sealed class DebouncedAction : IDisposable
{
private readonly GCHandle _state;
private object? _parameter;
private readonly AsyncAction<object, object?> _action;
private readonly TimeSpan _delay;
private readonly CancellationTokenSource _cts = new();
public DebouncedAction(object state, AsyncAction<object, object?> action, TimeSpan delay)
{
_state = GCHandle.Alloc(state, GCHandleType.Weak);
_action = action;
_delay = delay;
}
private Timer? _timer;
public void Invoke(object? parameter = null)
{
if (!_state.IsAllocated || _state.Target is null)
{
return;
}
lock (_action)
{
var restoreFlow = false;
try
{
if (!ExecutionContext.IsFlowSuppressed())
{
// avoid capturing async locals into the timer
ExecutionContext.SuppressFlow();
restoreFlow = true;
}
_parameter = parameter;
_timer ??= new(Callback, this, _delay, Timeout.InfiniteTimeSpan);
}
finally
{
if (restoreFlow)
{
ExecutionContext.RestoreFlow();
}
}
}
}
private static async void Callback(object? o)
{
try
{
var self = (DebouncedAction)o!;
try
{
if (self._state is { IsAllocated: true, Target: { } state })
{
await self._action(state, self._parameter, self._cts.Token)
.ConfigureAwait(false);
}
}
finally
{
self.Cancel();
}
}
catch (Exception e)
{
typeof(DebouncedAction).Log().LogError(e, "Failed to execute debounced action");
}
}
private void Cancel()
{
lock (_action)
{
_timer?.Dispose();
_timer = null;
_parameter = null;
}
}
public void Dispose()
{
Cancel();
_cts.Dispose();
}
}Additional context
Add any other context or screenshots about the feature request here.