Core · guide
Touch action
How an element keeps a finger from scrolling the view around it — the `touch-action` property, the flags it reads as, the chain the document intersects to answer a scroll view, and why a mouse is never governed by it.
Edit this page on GitHubDocuments
What it is
touch-action is CSS's answer to a question every touch interface has: when a finger lands on a
slider inside a list and moves, who gets it? The user agent's default — here a ScrollView
dragging its content under any finger — is what the property withholds.
.slider { touch-action: none; } /* the finger is the slider's; the list never scrolls */.carousel { touch-action: pan-y; } /* a vertical swipe scrolls the page, a horizontal one is ours */The cascade resolves it like any other declaration, and UiDocument.TouchActionBetween is the one
reader: it intersects the property over the chain from the element a finger landed on up to the one
that would scroll. ⚠ There is no separate single-element reader, deliberately. The property does
not inherit, so a chain that starts and ends at the same element — TouchActionBetween(el, el) — is
exactly the value the cascade resolved on it, and a second method spelling that was a public API
nothing called.
What it is for
Keeping a gesture. A control that drags — a slider, a colour wheel, a canvas — declares none
and the scroll view above it declines the finger. Without the property the view took every touch
drag, and a control's only recourse was to capture the pointer on the press, which stops a tap
on it from scrolling too.
Splitting the axes. pan-y on a horizontal carousel means a diagonal swipe scrolls the page
straight down and the horizontal half goes nowhere; a horizontal swipe never becomes a scroll at all
and is the carousel's to take. pan-x is the mirror.
Splitting a direction. pan-right beside pan-y is swipe-to-reveal: a leftward swipe never
becomes a scroll and opens the panel, a rightward one is an ordinary scroll that may wobble back.
⚠ The directional keywords are a test on how the gesture begins, not a boundary check. Pointer
Events § 6 says the user agent may consider the touch "only for the purposes of scrolling that
starts in the given direction", and the recogniser has always carried that number — the slop travel
that made the press a drag, DragEvent.TotalX/TotalY. pan-left admits a finger travelling
right, because that is the gesture whose content scrolls toward the left; once admitted, the whole
axis is open for the rest of the gesture.
Using it
Declare it in a stylesheet, or write the utility: touch-none, touch-pan-x, touch-pan-left,
touch-pan-right, touch-pan-y, touch-pan-up, touch-pan-down, touch-manipulation,
touch-auto.
⚠ It does not inherit, and an ancestor's declaration still reaches the finger. The document
intersects touch-action over every element from the touched one up to and including the scroll
view, which is the specification's rule — so none on a panel declines the scroll for any child,
and a scroll view that says none on itself has declined its own default.
⚠ Touch and pen only. A mouse drag on a view that asked for ScrollView.DragToScroll scrolls
straight through a none element. No browser lets the property stop a mouse and no author expects
it to, and PointerEvent.PointerType is what makes the two distinguishable at the arbitration
point — see Pointer devices.
⚠ Declined is not handled. A scroll view that declines a drag leaves the event unhandled, so it goes on bubbling to whatever is above; an outer scroll view walks the same chain and finds the same answer.
What the control theme already declares
ControlTheme.vcss writes the property on the controls that own a finger, so a panel that puts one
of these inside a ScrollView needs no rule of its own:
scrollbar, split-bar { touch-action: none; }slider, range-slider { touch-action: pan-y; }slider.vertical, range-slider.vertical { touch-action: pan-x; }numeric-input { touch-action: pan-y; }⚠ A slider gives the list the axis it does not read, and that needed the control to change
first. It said none until #1507, so a list of sliders scrolled only from the gaps between them.
pan-y alone would have been wrong while a slider set its value on the press: a finger landing on
one to scroll would have jumped the thumb to where it landed. So a slider now decides on the drag, as
a browser's range input does — under a finger or a pen the press moves and captures nothing; a drag
along the track scrubs and is handled before the view sees it; a drag across it is not handled,
bubbles, and is the view's under pan-y; and a tap sets the value where it landed. A mouse still
sets the value on the press.
⚠ Capturing the pointer is not enough, which is why these rules have to exist. A capture
redirects the raw pointer events; the DragEvent the recogniser reads out of them is raised on the
pressed control and bubbles past it. Before these declarations a finger dragging a slider inside a
list moved the thumb and scrolled the list, and a finger on a scrollbar moved the same offset
twice.
⚠ numeric-input is pan-y rather than none, and the difference is the whole point of the axis
keywords. The scrub reads the horizontal travel and nothing else, so the vertical half of a finger's
travel is still the list's. A blanket none there would make a form of numeric fields unscrollable
from anywhere a finger naturally lands.
⚠ The text fields are not in that list, and are settled in the control instead. TextField
captures the pointer for its selection drag, so a finger dragging inside a textbox or a textarea
in a scroll view used to move the caret and scroll the view by the whole travel. Neither keyword
is the answer — none makes a form of text fields unscrollable from anywhere a finger lands, and
pan-y does not help, since the drag that scrolls is the vertical one. So the field does what a
browser does: a finger or a pen begins no selection drag. The drag is the scroll view's; the focus
and the caret arrive on the tap, when the release has said the press was not a scroll; and a finger
selects a word by a double tap or by holding still (a LongPressEvent). A mouse selects by dragging
exactly as before. textbox, secure-textbox, textarea and search-box therefore declare nothing,
and must not: a finger on them is meant to reach the view.
What the advanced theme declares
AdvancedTheme.vcss continues the list for Vixen.Ui.Controls.Advanced:
node-canvas, node-minimap, viewport, image-view, curve-editor, color-field, dock-splitter,timeline-lanes { touch-action: none; }color-strip, gradient-rail, timeline-ruler, data-header-cell { touch-action: pan-y; }⚠ A row names the element the finger lands on, which is not always the control that captures.
DataGrid captures on the grid, but only for a press on a header cell — none on data-grid would
make every row of a table unscrollable by finger, which is the one gesture a table is scrolled with.
A Timeline is two rows because it is two gestures: its ruler scrubs along time (pan-y) and its
lanes draw a marquee in both axes (none). The editor's paint strokes need no row of their own,
because PaintMeshView and PaintUvView capture on the ImageView they own.
⚠ CodeEditor is the text-field shape, settled the same way. Its capture is a text selection,
and the view a finger drags is the editor's own scroller — which a row on code-editor could not
even reach, because the editor is above that view rather than between it and the finger. A finger's
drag scrolls the code and selects nothing; the caret arrives on the tap and a word on a double tap or
a long press. A fold arrow answers a finger's tap rather than its press, so a scroll that starts in
the gutter folds nothing, and a fold leaves the caret where it was, as a mouse's does.
⚠ The gutter scrolls the code, not the view around the editor. code-gutter is the scroller's
sibling, because it must follow the vertical scroll and not the horizontal one, and a sibling's drag
and wheel bubble past the scroller to whatever holds the editor — so until #1365 a finger on the line
numbers scrolled the page and never the code. ScrollView.ScrollFrom(region) routes a region outside
a view into that view's own wheel and drag handling, fling, rubber band and chaining included; a
wheel the code cannot take still goes on to the view outside. For touch-action the region is treated
as part of the view: the chain is read from the finger to the region and then on the view itself, and
a declaration above both narrows the margin no more than it narrows the content.
⚠ Every production CapturePointer call is accounted for, and a new one fails a test.
Core/Vixen.Ui.Controls.Advanced.Tests/TouchActionCensus.txt lists each file that captures, how many
times, and the row that answers it; TouchActionCensusTests holds the list to the tree (.cs and
.vxml, outside test projects) and each answer to what the two themes actually resolve. A control
that starts capturing a finger adds a row to the theme and a line to the census in the same change.
⚠ The test lives in Vixen.Ui.Controls.Advanced.Tests because it resolves both themes, and it
polices Vixen.Ui.Controls as well — so a new capture in TextField is green in that project's own
suite and red only in the advanced one. Run both.
touch-pinch-zoom is deliberately not a utility. The keyword parses (TouchAction.PinchZoom), but
nothing here performs a pinch as a user-agent default, so the class would resolve and configure
nothing.
Examples
Reading the set a scroll view would see for a press on an element:
var allowed = document.TouchActionBetween(knob, list);if ((allowed & TouchAction.PanY) == 0) { // The list will not scroll vertically from a finger on the knob.}Driving a finger from a test and asserting the view stayed put:
test.PointerType = PointerType.Touch;test.MovePointer(50f, 50f);test.PressPointer();test.MovePointer(50f, 20f);test.ReleasePointer();Assert.Equal(0f, list.ScrollTop);See also
* Pointer devices — the field that says whether the press came from a finger * Cursors — what the pointer looks like, which only a device with a cursor has