Browse Source

Scroll the tree by offset, so no container is left stranded

ListBox.ScrollIntoView realises a container for its target, arranges it at its
own desired width, parks it aside for a few layout passes and then drops the
reference. A container those passes do not adopt back into the realized range
stays a visible child of the panel that nothing arranges again: it keeps
painting its old item, at its old position and its own narrow width, over
whatever row now occupies that spot - the ghost row drawn across another.

Rows here are a uniform height, so the offset a row's index implies reaches the
same place without ever entering that path. ScrollIntoView is hidden on
SharpTreeView so the obvious call lands on the safe one; hiding is not
enforcement, since a call through an ItemsControl-typed reference still reaches
the base method, but no call site has such a reference.

Assisted-by: Claude:claude-opus-5:Claude Code
pull/4076/head
Siegfried Pammer 3 weeks ago
parent
commit
8651329710
  1. 124
      ILSpy/Controls/TreeView/SharpTreeView.cs

124
ILSpy/Controls/TreeView/SharpTreeView.cs

@ -197,8 +197,8 @@ namespace ICSharpCode.ILSpy.Controls.TreeView @@ -197,8 +197,8 @@ namespace ICSharpCode.ILSpy.Controls.TreeView
}
if (lastVisibleChild != node)
{
ScrollIntoView(lastVisibleChild);
Dispatcher.UIThread.Post(() => ScrollIntoView(node), DispatcherPriority.Loaded);
ScrollRowIntoView(lastVisibleChild, centre: false);
Dispatcher.UIThread.Post(() => ScrollRowIntoView(node, centre: false), DispatcherPriority.Loaded);
}
}
@ -251,48 +251,100 @@ namespace ICSharpCode.ILSpy.Controls.TreeView @@ -251,48 +251,100 @@ namespace ICSharpCode.ILSpy.Controls.TreeView
/// <summary>
/// Reveals <paramref name="node"/> centred in the viewport so the user's eye lands on a
/// newly selected row, rather than at the nearest edge (where <see cref="ListBox.ScrollIntoView"/>
/// leaves it). Skips the move when the row is already fully visible, so clicking a visible
/// row -- or selecting a freshly-loaded top-level entry that's already on screen -- never
/// yanks the viewport.
/// newly selected row, rather than at the nearest edge. Skips the move when the row is
/// already roughly centred, so re-selecting it never twitches the viewport. We
/// deliberately do NOT skip a merely-visible row sitting at an edge: the ListBox's
/// AutoScrollToSelectedItem drags the selected row to the nearest edge first, and a
/// reveal should still pull it to the centre from there. (Skipping an already-visible
/// row is decided one level up, before AutoScroll runs, in the model-&gt;tree sync --
/// see TreeSelectionBinder.SyncModelToTree.)
/// </summary>
void CenterNodeInView(SharpTreeNode node)
void CenterNodeInView(SharpTreeNode node) => ScrollRowIntoView(node, centre: true);
/// <summary>
/// Hides <see cref="ItemsControl.ScrollIntoView(object)"/> so the obvious way to reveal a
/// row is also the safe one -- see <see cref="ScrollRowIntoView"/> for what the base
/// method does to a container it realises. Hiding is not enforcement: a call through an
/// <see cref="ItemsControl"/>-typed reference still reaches the base method. It is here
/// so that code written later, on a SharpTreeView-typed variable, lands on the safe path
/// without its author having to know any of this.
/// </summary>
public new void ScrollIntoView(object item)
{
var scrollViewer = this.GetVisualDescendants().OfType<ScrollViewer>().FirstOrDefault();
if (scrollViewer is null)
{
ScrollIntoView(node);
if (item is SharpTreeNode node)
ScrollRowIntoView(node, centre: false);
}
/// <inheritdoc cref="ScrollIntoView(object)"/>
public new void ScrollIntoView(int index)
{
if (flattener is { } rows && (uint)index < (uint)rows.Count)
ScrollIntoView(rows[index]);
}
/// <summary>
/// Scrolls to a row by setting the offset the row's index implies, rather than calling
/// <see cref="ListBox.ScrollIntoView(object)"/>.
///
/// That method realises a container for the target, arranges it at its own desired
/// width, parks it aside for the duration of a few layout passes and then drops the
/// reference. A container the passes do not adopt back into the realized range is left
/// a visible child of the panel that nothing arranges again -- it keeps painting its
/// old item at its old position, over whatever row now occupies that spot. Rows here
/// are a uniform height, so the arithmetic below reaches the same place without ever
/// entering that code path.
/// </summary>
void ScrollRowIntoView(SharpTreeNode node, bool centre)
{
if (this.GetVisualDescendants().OfType<ScrollViewer>().FirstOrDefault() is not { } scrollViewer)
return;
}
if (flattener is null || flattener.IndexOf(node) is not (>= 0 and var index))
return;
// An expansion has just changed how many rows there are; the extent has to catch up
// before an offset can be clamped against it.
UpdateLayout();
// If the row is already realised and roughly centred, leave the viewport alone -- this
// keeps a re-selection of an already-centred row from twitching. We deliberately do NOT
// skip a merely-visible row sitting at an edge: the ListBox's AutoScrollToSelectedItem
// drags the selected row to the nearest edge first, and a reveal should still pull it to
// the centre from there. (Skipping an already-visible row is decided one level up, before
// AutoScroll runs, in the model->tree sync -- see TreeSelectionBinder.SyncModelToTree.)
if (ContainerFromItem(node) is Control visible && visible.IsVisible
&& visible.TranslatePoint(new Point(0, 0), scrollViewer) is { } top)
double rowHeight = RowHeight();
double viewport = scrollViewer.Viewport.Height;
double rowTop = index * rowHeight;
double offset = scrollViewer.Offset.Y;
if (centre)
{
var rowMid = top.Y + visible.Bounds.Height / 2;
var viewportMid = scrollViewer.Viewport.Height / 2;
if (Math.Abs(rowMid - viewportMid) <= visible.Bounds.Height)
double rowMidInViewport = rowTop - offset + rowHeight / 2;
if (Math.Abs(rowMidInViewport - viewport / 2) <= rowHeight)
return;
offset = rowTop - (viewport - rowHeight) / 2;
}
// Bring it on screen (edge), force layout so the container realises, then offset so the
// row sits at the vertical centre.
ScrollIntoView(node);
UpdateLayout();
if (ContainerFromItem(node) is not Control row)
return;
if (row.TranslatePoint(new Point(0, 0), scrollViewer) is not { } rowTop)
else if (rowTop < offset)
{
offset = rowTop;
}
else if (rowTop + rowHeight > offset + viewport)
{
offset = rowTop + rowHeight - viewport;
}
else
{
return;
var desiredTop = (scrollViewer.Viewport.Height - row.Bounds.Height) / 2;
var newOffsetY = scrollViewer.Offset.Y + (rowTop.Y - desiredTop);
var maxOffset = Math.Max(0, scrollViewer.Extent.Height - scrollViewer.Viewport.Height);
newOffsetY = Math.Clamp(newOffsetY, 0, maxOffset);
scrollViewer.Offset = new Vector(scrollViewer.Offset.X, newOffsetY);
}
double maxOffset = Math.Max(0, scrollViewer.Extent.Height - viewport);
scrollViewer.Offset = new Vector(scrollViewer.Offset.X, Math.Clamp(offset, 0, maxOffset));
}
/// <summary>The height of a row, taken from one that exists rather than assumed. The
/// fallback matches the item template, for the moment before the first row is
/// realised.</summary>
double RowHeight()
{
foreach (var container in GetRealizedContainers())
{
if (container.Bounds.Height > 0)
return container.Bounds.Height;
}
return 20;
}
/// <summary>

Loading…
Cancel
Save