Skip to content

Latest commit

 

History

History
100 lines (78 loc) · 5.36 KB

File metadata and controls

100 lines (78 loc) · 5.36 KB

The scrolling stack

Three files, one design. src/UI/Common/TouchDragScrollViewer.cs is the single origin — its class comment carries the full rationale; SnappyScrollPhysics.cs and ScrollCacheDuringTouch.cs document only what is unique to them and point back here.

File Role
TouchDragScrollViewer.cs FluentWpfCore.SmoothScrollViewer subclass: the touch-drag gesture (延迟捕获), the MaxOffset push, the scrollbar binding dance
SnappyScrollPhysics.cs IScrollPhysics replacement: library-equivalent feel + two bugfixes
ScrollCacheDuringTouch.cs Attached behavior: windowed BitmapCache during touch drags only

Where it is used: ONE settings page + the five detail views' process lists. All six scrollers are TouchDragScrollViewer, all six set Physics to SnappyScrollPhysics, and all six set ScrollCacheDuringTouch.Enabled="True".

Why it exists at all

SmoothScrollViewer is v3 architecture — visual/logical separation: RenderTransform scrolls at full frame rate while the logical offset syncs infrequently. We keep it (and keep the library's default feel) for two bugs in 1.0.5 that this subclass fixes:

  1. Precise mode IsStable is never true in the library — the render loop can only stop by hitting a boundary. Scrolling to the middle of a page with a touchpad then left the content with IsHitTestVisible=false, freezing every click. Fixed in SnappyScrollPhysics: stable means "remaining < StopDistance AND input quiet > PreciseIdleSeconds".
  2. Out-of-range accumulation — scroll past the bottom/top and the remaining distance piles up outside the range; the reverse scroll must "pay it back" before anything moves (the library's default physics has the same disease). Fixed by clamping every frame's target into [0, MaxOffset], with MaxOffset pushed from TouchDragScrollViewer.OnScrollChanged's ScrollableHeight.

The rate constants are strictly equivalent to the library defaults (wheel k≈9.55/s, precise k≈18.4/s; k = -144·ln(friction)), because the user explicitly asked for the default feel back after trying faster values. Only the two bugfixes deviate. Do not "tune" them.

Touch drag = 延迟捕获 (delayed capture)

Both off-the-shelf routes are unusable, each for a measured reason:

  • The library's manipulation (IsEnableSmoothManipulating) captures the touch point in TouchDown, so a tap no longer promotes to a mouse click and every switch/button inside the page stops responding to touch (2026-08-01).
  • Native PanningMode keeps taps working, but it is layout-driven: every touch move (90–120Hz) triggers a full page layout, dropping frames on weak-GPU touch machines. BitmapCache removed the per-frame repaint cost and the stutter remained (2026-08-02 log: cache engaged, still janky).

The adopted flow:

  1. TouchDown only observes — no handling, no capture, so a tap promotes to a click normally. A gesture starting on the ScrollBar is not tracked at all; the bar keeps its native promotion/drag.
  2. Past the threshold, capture the touch point — and balance the MouseLeftButtonDown the first TouchDown already promoted with Mouse.Capture(null) (losing capture drops the pressed state and suppresses the Click) — then feed the move deltas straight to physics as AnimatedScrollToVerticalOffset(VerticalOffset - dy, true), delivered per frame, no accumulated error.
  3. On release, if the sampled velocity is high enough, run the inertia decay loop (v *= e^(-k·dt)); it stops at a boundary, at low speed, or on a new press.

Known tradeoffs (do not treat as bugs)

  • A drag starting on a ComboBox opens its dropdown first, and on a TextBox it focuses first — the drag aborts at that point; the control does not get stuck. Same as native PanningMode.
  • While physics is rendering, content has IsHitTestVisible=false, so a tap within ~0.1s after the scroll visually settles is swallowed. Hover/tooltips pause during scrolling.

The scrollbar / style trap

TouchDragScrollViewer does NOT inherit iNKORE's implicit ScrollViewer style: WPF keyless styles match TargetType exactly and do not reach derived controls. With no Style set, Style == null (verified by dump) and the control falls back to the classic Aero scrollbar, which does not auto-hide.

Therefore all six scrollers set:

Style="{DynamicResource {x:Type ScrollViewer}}"

DynamicResource (not StaticResource) so it follows light/dark scheme switches. The template then still contains PART_VerticalScrollBar/PART_HorizontalScrollBar, which the control relies on: during scrolling it clears the scrollbars' Value bindings and assigns values manually, then rebuilds them once the scroll settles.

The BitmapCache window

Never leave BitmapCache on permanently — hover/expand/switch animations would re-rasterize the whole page every frame, which is worse. The attached behavior makes it a window: threshold-crossing drag turns it on (a light tap pays no rasterization cost), inertia keeps it alive via ScrollChanged, and 600ms of silence restores the original CacheMode.

Subscription must use handledEventsTooTouchDragScrollViewer's drag marks move/up as handled and class handlers run before instance handlers, so without the flag the behavior never sees the events and the cache never engages.