@astryxdesign/core 0.7.6 → 0.7.7-canary.0d1ddcd

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/dist/AppShell/AppShell.d.ts.map +1 -1
  2. package/dist/AppShell/AppShell.js +23 -3
  3. package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -1
  4. package/dist/BottomSheet/BottomSheetPanel.js +16 -9
  5. package/dist/Chat/ChatComposerInput.d.ts +9 -0
  6. package/dist/Chat/ChatComposerInput.d.ts.map +1 -1
  7. package/dist/Chat/ChatComposerInput.js +2 -0
  8. package/dist/Chat/ChatMessage.js +2 -2
  9. package/dist/Chat/chatMessage.vars.stylex.d.ts +8 -8
  10. package/dist/Chat/chatMessage.vars.stylex.js +8 -8
  11. package/dist/CommandPalette/CommandPaletteInput.d.ts.map +1 -1
  12. package/dist/CommandPalette/CommandPaletteInput.js +8 -1
  13. package/dist/DateInput/MonthScroller.d.ts.map +1 -1
  14. package/dist/DateInput/MonthScroller.js +72 -2
  15. package/dist/Drawer/Drawer.d.ts.map +1 -1
  16. package/dist/Drawer/Drawer.js +6 -0
  17. package/dist/DropdownMenu/useMenuOverflow.d.ts +13 -3
  18. package/dist/DropdownMenu/useMenuOverflow.d.ts.map +1 -1
  19. package/dist/DropdownMenu/useMenuOverflow.js +31 -8
  20. package/dist/Field/PanelSearchInput.d.ts.map +1 -1
  21. package/dist/Field/PanelSearchInput.js +8 -1
  22. package/dist/Field/textEntryTraits.d.ts +6 -6
  23. package/dist/Field/textEntryTraits.js +6 -6
  24. package/dist/Link/Link.d.ts.map +1 -1
  25. package/dist/Link/Link.js +1 -0
  26. package/dist/Selector/Selector.d.ts.map +1 -1
  27. package/dist/Selector/Selector.js +25 -13
  28. package/dist/Slider/Slider.d.ts.map +1 -1
  29. package/dist/Slider/Slider.js +404 -131
  30. package/dist/Slider/sliderTouchController.d.ts +189 -0
  31. package/dist/Slider/sliderTouchController.d.ts.map +1 -0
  32. package/dist/Slider/sliderTouchController.js +1017 -0
  33. package/dist/Slider/useSliderTouch.d.ts +22 -0
  34. package/dist/Slider/useSliderTouch.d.ts.map +1 -0
  35. package/dist/Slider/useSliderTouch.js +56 -0
  36. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  37. package/dist/Typeahead/BaseTypeahead.js +19 -9
  38. package/dist/astryx.css +33 -6
  39. package/dist/gestures/GestureArena.d.ts.map +1 -1
  40. package/dist/gestures/GestureArena.js +4 -1
  41. package/dist/gestures/GestureHost.d.ts +72 -2
  42. package/dist/gestures/GestureHost.d.ts.map +1 -1
  43. package/dist/gestures/GestureHost.js +643 -5
  44. package/dist/gestures/GestureRecognizer.d.ts +15 -1
  45. package/dist/gestures/GestureRecognizer.d.ts.map +1 -1
  46. package/dist/gestures/GestureRecognizer.js +12 -0
  47. package/dist/gestures/PanGestureRecognizer.d.ts +8 -0
  48. package/dist/gestures/PanGestureRecognizer.d.ts.map +1 -1
  49. package/dist/gestures/PinchGestureRecognizer.d.ts +5 -1
  50. package/dist/gestures/PinchGestureRecognizer.d.ts.map +1 -1
  51. package/dist/gestures/PinchGestureRecognizer.js +10 -0
  52. package/dist/gestures/ScreenEdgePanGestureRecognizer.d.ts +3 -3
  53. package/dist/gestures/ScreenEdgePanGestureRecognizer.d.ts.map +1 -1
  54. package/dist/gestures/ScreenEdgePanGestureRecognizer.js +7 -5
  55. package/dist/gestures/nativeScroll.d.ts +16 -2
  56. package/dist/gestures/nativeScroll.d.ts.map +1 -1
  57. package/dist/gestures/nativeScroll.js +39 -8
  58. package/dist/gestures/recognizerInternals.d.ts +4 -2
  59. package/dist/gestures/recognizerInternals.d.ts.map +1 -1
  60. package/dist/gestures/types.d.ts +11 -0
  61. package/dist/gestures/types.d.ts.map +1 -1
  62. package/dist/gestures/types.js +7 -0
  63. package/dist/gestures/wheelSegmenter.d.ts +45 -0
  64. package/dist/gestures/wheelSegmenter.d.ts.map +1 -0
  65. package/dist/gestures/wheelSegmenter.js +98 -0
  66. package/dist/motion/animator.d.ts.map +1 -1
  67. package/dist/motion/animator.js +140 -19
  68. package/dist/motion/paging.d.ts +58 -0
  69. package/dist/motion/paging.d.ts.map +1 -0
  70. package/dist/motion/paging.js +128 -0
  71. package/dist/motion/uikit/index.d.ts +2 -1
  72. package/dist/motion/uikit/index.d.ts.map +1 -1
  73. package/dist/motion/uikit/index.js +2 -1
  74. package/dist/motion/uikit/paging.d.ts +160 -0
  75. package/dist/motion/uikit/paging.d.ts.map +1 -0
  76. package/dist/motion/uikit/paging.js +162 -0
  77. package/dist/motion/uikit/scroll.d.ts +0 -19
  78. package/dist/motion/uikit/scroll.d.ts.map +1 -1
  79. package/dist/motion/uikit/scroll.js +2 -21
  80. package/dist/motion/uikit/slider.d.ts +97 -0
  81. package/dist/motion/uikit/slider.d.ts.map +1 -0
  82. package/dist/motion/uikit/slider.js +111 -0
  83. package/dist/motion/uikit/springs.d.ts +46 -1
  84. package/dist/motion/uikit/springs.d.ts.map +1 -1
  85. package/dist/motion/uikit/springs.js +48 -4
  86. package/package.json +5 -5
  87. package/src/AppShell/AppShell.doc.mjs +10 -0
  88. package/src/AppShell/AppShell.tsx +48 -2
  89. package/src/BottomSheet/BottomSheetPanel.tsx +16 -9
  90. package/src/Chat/Chat.doc.mjs +1 -0
  91. package/src/Chat/ChatComposerInput.doc.mjs +11 -0
  92. package/src/Chat/ChatComposerInput.tsx +11 -0
  93. package/src/Chat/ChatMessage.tsx +2 -2
  94. package/src/Chat/chatMessage.vars.stylex.ts +8 -8
  95. package/src/CommandPalette/CommandPaletteInput.tsx +6 -0
  96. package/src/DateInput/MonthScroller.tsx +63 -5
  97. package/src/Drawer/Drawer.tsx +6 -0
  98. package/src/DropdownMenu/useMenuOverflow.ts +44 -8
  99. package/src/Field/PanelSearchInput.tsx +6 -0
  100. package/src/Field/textEntryTraits.ts +6 -6
  101. package/src/Link/Link.tsx +3 -0
  102. package/src/Selector/Selector.tsx +29 -2
  103. package/src/Slider/Slider.doc.mjs +11 -7
  104. package/src/Slider/Slider.tsx +539 -132
  105. package/src/Slider/sliderTouchController.ts +1209 -0
  106. package/src/Slider/useSliderTouch.ts +78 -0
  107. package/src/Typeahead/BaseTypeahead.tsx +16 -8
  108. package/src/gestures/GestureArena.ts +1 -0
  109. package/src/gestures/GestureHost.ts +765 -5
  110. package/src/gestures/GestureRecognizer.ts +28 -0
  111. package/src/gestures/PanGestureRecognizer.ts +8 -0
  112. package/src/gestures/PinchGestureRecognizer.ts +16 -1
  113. package/src/gestures/README.md +63 -0
  114. package/src/gestures/ScreenEdgePanGestureRecognizer.ts +7 -5
  115. package/src/gestures/nativeScroll.ts +49 -9
  116. package/src/gestures/recognizerInternals.ts +9 -2
  117. package/src/gestures/types.ts +12 -0
  118. package/src/gestures/wheelSegmenter.ts +114 -0
  119. package/src/motion/MEASUREMENTS.md +8 -1
  120. package/src/motion/README.md +3 -2
  121. package/src/motion/animator.ts +193 -25
  122. package/src/motion/paging.ts +138 -0
  123. package/src/motion/uikit/index.ts +2 -1
  124. package/src/motion/uikit/paging.ts +162 -0
  125. package/src/motion/uikit/scroll.ts +2 -18
  126. package/src/motion/uikit/slider.ts +105 -0
  127. package/src/motion/uikit/springs.ts +37 -2
  128. package/dist/motion/selectPage.d.ts +0 -26
  129. package/dist/motion/selectPage.d.ts.map +0 -1
  130. package/dist/motion/selectPage.js +0 -55
  131. package/src/motion/selectPage.ts +0 -55
@@ -517,6 +517,14 @@ interface StartGroup {
517
517
  madeInFrame?: boolean;
518
518
  /** The task it was made in, with every call made in that task. */
519
519
  task?: TaskMark;
520
+ /**
521
+ * Per channel, when its animation's first frame ran without a known start.
522
+ * That frame came too late to stand for the start, and the browser had not
523
+ * told it yet. The compositor starts the animation within about a frame of
524
+ * this time. Until the browser tells its start, a read of the channel takes
525
+ * the curve from here.
526
+ */
527
+ latest?: Map<Channel, number>;
520
528
  /** A later call in the same task that took over its curves. */
521
529
  adoptedBy?: StartGroup;
522
530
  readonly resolve: (start: number) => void;
@@ -1044,7 +1052,7 @@ export function createAnimator(
1044
1052
  if (segment == null) {
1045
1053
  return null;
1046
1054
  }
1047
- if (time >= segment.start + segment.duration) {
1055
+ if (time >= endOf(property, segment)) {
1048
1056
  return null;
1049
1057
  }
1050
1058
  return segment;
@@ -1070,16 +1078,89 @@ export function createAnimator(
1070
1078
  };
1071
1079
 
1072
1080
  /**
1073
- * The value on screen at `time`. A curve the browser has not started is at
1074
- * its start up to now; a later time reads the curve from its provisional
1075
- * start, the model's best guess until the browser's start moves it (a
1076
- * companion that samples a curve ahead follows its shape).
1081
+ * A held curve's start once a frame dated its call, as the browser's start
1082
+ * moves it: by that frame's lead over the call's own time.
1083
+ */
1084
+ const datedFromLatest = (
1085
+ segment: Segment,
1086
+ latest: number | undefined,
1087
+ ): {latest: number; start: number} | null =>
1088
+ latest == null || segment.group == null
1089
+ ? null
1090
+ : {latest, start: segment.start + latest - segment.group.provisional};
1091
+
1092
+ /**
1093
+ * When a waiting curve's call had its first frame, which put the curve on
1094
+ * screen. Undefined for a curve that does not wait, or whose call has had
1095
+ * no first frame.
1096
+ */
1097
+ const shownSince = (
1098
+ property: MotionProperty,
1099
+ segment: Segment,
1100
+ ): number | undefined =>
1101
+ segment.group?.resolved === false
1102
+ ? segment.group.latest?.get(CHANNEL_OF[property])
1103
+ : undefined;
1104
+
1105
+ /** A curve's end, dated from its call's first frame while it waits. */
1106
+ const endOf = (property: MotionProperty, segment: Segment): number =>
1107
+ (datedFromLatest(segment, shownSince(property, segment))?.start ??
1108
+ segment.start) + segment.duration;
1109
+
1110
+ /**
1111
+ * Takes a curve on screen out of the call that still waits for the
1112
+ * browser's start. The curve keeps its course, dated from the call's first
1113
+ * frame. A call that has no channel left starts at that frame.
1114
+ */
1115
+ const leaveWaitingCall = (
1116
+ segment: Segment,
1117
+ channel: Channel,
1118
+ latest: number,
1119
+ ) => {
1120
+ const group = segment.group;
1121
+ if (group == null) {
1122
+ return;
1123
+ }
1124
+ const index = group.segments.indexOf(segment);
1125
+ if (index >= 0) {
1126
+ group.segments.splice(index, 1);
1127
+ }
1128
+ segment.start += latest - group.provisional;
1129
+ segment.group = undefined;
1130
+ if (!CHANNELS[channel].some(p => segments.get(p)?.group === group)) {
1131
+ group.latest?.delete(channel);
1132
+ group.channels.delete(channel);
1133
+ }
1134
+ if (group.channels.size === 0 && group.segments.length === 0) {
1135
+ resolveGroup(group, latest);
1136
+ }
1137
+ };
1138
+
1139
+ /**
1140
+ * The value on screen at `time`. Until the browser reports its start, a
1141
+ * held curve shows its first value. A frame that ran without that start
1142
+ * dates the curve. Without that frame, a later time reads from the
1143
+ * provisional start, so a companion that samples ahead follows the curve's
1144
+ * shape.
1077
1145
  */
1078
1146
  const valueAt = (property: MotionProperty, time: number): number => {
1079
1147
  const segment = segments.get(property);
1080
- if (segment != null && isHeld(segment) && time <= now()) {
1148
+ if (segment != null && isHeld(segment)) {
1149
+ // The compositor starts the call near its first frame. The curve is
1150
+ // dated as the browser's start would date it. A curve that joined by an
1151
+ // older clock keeps its lead over the call's own time.
1152
+ const latest = segment.group?.latest?.get(CHANNEL_OF[property]);
1153
+ const dated = datedFromLatest(segment, latest);
1154
+ if (dated != null) {
1155
+ const elapsed = Math.max(time, dated.latest) - dated.start;
1156
+ return elapsed >= segment.duration
1157
+ ? segment.target
1158
+ : segment.at(elapsed);
1159
+ }
1081
1160
  // Nothing has moved on screen until the browser starts the call.
1082
- return segment.at(0);
1161
+ if (time <= now()) {
1162
+ return segment.at(0);
1163
+ }
1083
1164
  }
1084
1165
  return curveAt(property, time);
1085
1166
  };
@@ -1087,10 +1168,22 @@ export function createAnimator(
1087
1168
  const velocityAt = (property: MotionProperty, time: number): number => {
1088
1169
  const held = segments.get(property);
1089
1170
  if (held != null && isHeld(held)) {
1090
- // Still at its start on screen, but moving in the model: a retarget
1091
- // carries the velocity the curve has reached by now, so a flick whose
1092
- // moves all land before the browser starts any keeps its speed.
1093
- return held.speed(Math.max(0, time - held.start));
1171
+ // Still at its start on screen, but moving in the model. A retarget
1172
+ // carries the velocity the curve has reached by now. So a flick whose
1173
+ // moves all land before the browser starts any keeps its speed. Past
1174
+ // its first frame, it reads from that frame, as its value does.
1175
+ const dated = datedFromLatest(
1176
+ held,
1177
+ held.group?.latest?.get(CHANNEL_OF[property]),
1178
+ );
1179
+ return held.speed(
1180
+ Math.max(
1181
+ 0,
1182
+ dated != null && time > dated.latest
1183
+ ? time - dated.start
1184
+ : time - held.start,
1185
+ ),
1186
+ );
1094
1187
  }
1095
1188
  const segment = segmentAt(property, time);
1096
1189
  if (segment == null || time < segment.start) {
@@ -1270,12 +1363,54 @@ export function createAnimator(
1270
1363
  // Settled curves become resting values.
1271
1364
  for (const property of properties) {
1272
1365
  const segment = segments.get(property);
1273
- if (segment != null && time >= segment.start + segment.duration) {
1366
+ if (segment != null && time >= endOf(property, segment)) {
1274
1367
  resting[property] = segment.target;
1275
1368
  segments.delete(property);
1276
1369
  }
1277
1370
  }
1278
1371
  const moving = properties.filter(p => segments.has(p));
1372
+ if (callGroup == null) {
1373
+ // A call that starts now rebuilds a channel the compositor has moving
1374
+ // since a first frame without a known start. The channel carries on
1375
+ // from that frame, with a start of its own, instead of starting its
1376
+ // curve again.
1377
+ for (const property of moving) {
1378
+ const segment = segments.get(property) as Segment;
1379
+ const latest = shownSince(property, segment);
1380
+ if (latest != null) {
1381
+ leaveWaitingCall(segment, channel, latest);
1382
+ }
1383
+ }
1384
+ }
1385
+ // An explicit call can rebuild a channel while one of its curves still
1386
+ // waits for the browser's start. The animation then joins the current
1387
+ // call, or else that waiting call, with no start of its own.
1388
+ const waiting =
1389
+ callGroup ??
1390
+ moving
1391
+ .map(p => (segments.get(p) as Segment).group)
1392
+ .find(group => group != null && !group.resolved) ??
1393
+ null;
1394
+ const rebuiltAfterFirstFrame = waiting?.framed === true;
1395
+ // The new animation starts at the frame that commits it, from its first
1396
+ // keyframe. That holds for a waiting call it joins after that call's
1397
+ // first frame, and for every waiting call it takes a curve from. So this
1398
+ // channel's reads below, and until that frame, take that first value.
1399
+ const restarted = new Set<StartGroup>();
1400
+ if (rebuiltAfterFirstFrame) {
1401
+ restarted.add(waiting);
1402
+ }
1403
+ if (callGroup != null) {
1404
+ for (const property of moving) {
1405
+ const group = (segments.get(property) as Segment).group;
1406
+ if (group != null && group !== callGroup && !group.resolved) {
1407
+ restarted.add(group);
1408
+ }
1409
+ }
1410
+ }
1411
+ for (const group of restarted) {
1412
+ group.latest?.delete(channel);
1413
+ }
1279
1414
  if (channel === 'translate') {
1280
1415
  // The animation built below starts from the value at `time` (its first
1281
1416
  // keyframe); a rebase or a retarget builds a new one, so a new start.
@@ -1308,15 +1443,6 @@ export function createAnimator(
1308
1443
  previous?.cancel();
1309
1444
  return;
1310
1445
  }
1311
- // A channel rebuilt by an explicit call (set, rebase, a retarget of
1312
- // another axis) while one of its curves still waits for the browser's
1313
- // start keeps that start: no explicit start for this animation.
1314
- const waiting =
1315
- callGroup ??
1316
- moving
1317
- .map(p => (segments.get(p) as Segment).group)
1318
- .find(group => group != null && !group.resolved) ??
1319
- null;
1320
1446
  // The keyframes start where the animation will: at the waiting call's
1321
1447
  // own time, which its start moves to the browser's (resolveGroup), so a
1322
1448
  // write later in the task keeps the screen on the model's clock.
@@ -1431,6 +1557,20 @@ export function createAnimator(
1431
1557
  waiting.channels.add(channel);
1432
1558
  // A cancel before the first frame rejects its ready promise.
1433
1559
  (animation as Partial<Animation>).ready?.catch(() => {});
1560
+ if (rebuiltAfterFirstFrame) {
1561
+ // The frame that commits it dates this channel's start again. The
1562
+ // call's other channels keep their own.
1563
+ requestFrame?.(() => {
1564
+ if (
1565
+ !waiting.resolved &&
1566
+ running.get(channel) === animation &&
1567
+ animation.startTime == null &&
1568
+ animation.playState === 'running'
1569
+ ) {
1570
+ (waiting.latest ??= new Map()).set(channel, now());
1571
+ }
1572
+ });
1573
+ }
1434
1574
  } else {
1435
1575
  // One clock: the keyframes were sampled from `time`.
1436
1576
  animation.startTime = time;
@@ -1937,10 +2077,14 @@ export function createAnimator(
1937
2077
  for (const channel of start === 'commit' ? [] : channels) {
1938
2078
  for (const property of CHANNELS[channel]) {
1939
2079
  const segment = segments.get(property);
2080
+ // A call's first frame puts its channels on screen, even before the
2081
+ // browser reports the start. So such a channel is not at rest either.
1940
2082
  if (
1941
2083
  segment != null &&
1942
- time < segment.start + segment.duration &&
1943
- (segment.group == null || segment.group.resolved)
2084
+ time < endOf(property, segment) &&
2085
+ (segment.group == null ||
2086
+ segment.group.resolved ||
2087
+ shownSince(property, segment) != null)
1944
2088
  ) {
1945
2089
  return null;
1946
2090
  }
@@ -2135,6 +2279,22 @@ export function createAnimator(
2135
2279
  ) {
2136
2280
  resolveGroup(group, now());
2137
2281
  }
2282
+ // The browser's start is still unread. The compositor starts the call
2283
+ // near this frame, so each moving channel's reads take the curve from
2284
+ // now until the browser tells its start.
2285
+ if (!group.resolved) {
2286
+ for (const channel of group.channels) {
2287
+ const animation = running.get(channel);
2288
+ if (
2289
+ animation != null &&
2290
+ group.animations.includes(animation) &&
2291
+ animation.startTime == null &&
2292
+ animation.playState === 'running'
2293
+ ) {
2294
+ (group.latest ??= new Map()).set(channel, now());
2295
+ }
2296
+ }
2297
+ }
2138
2298
  });
2139
2299
  ready.then(
2140
2300
  () => syncGroup(group),
@@ -3643,9 +3803,11 @@ export function createAnimator(
3643
3803
  }
3644
3804
  owners.add(segment.owner);
3645
3805
  // A curve still waiting for the browser's start stays in its call:
3646
- // it starts with that call's commit.
3806
+ // it starts with that call's commit. A curve whose call had its first
3807
+ // frame is on screen already, so it leaves the call (leaveWaitingCall).
3808
+ const latest = shownSince(property, segment);
3647
3809
  const group =
3648
- segment.group != null && !segment.group.resolved
3810
+ latest == null && segment.group != null && !segment.group.resolved
3649
3811
  ? segment.group
3650
3812
  : undefined;
3651
3813
  const replace = (next: Segment) => {
@@ -3662,6 +3824,9 @@ export function createAnimator(
3662
3824
  if (segment.spring != null) {
3663
3825
  const from = valueAt(property, time) + delta;
3664
3826
  const velocity = velocityAt(property, time);
3827
+ if (latest != null) {
3828
+ leaveWaitingCall(segment, CHANNEL_OF[property], latest);
3829
+ }
3665
3830
  const curve = solveSpring(toSpring(segment.spring), {
3666
3831
  from,
3667
3832
  to: segment.target,
@@ -3682,6 +3847,9 @@ export function createAnimator(
3682
3847
  spring: segment.spring,
3683
3848
  });
3684
3849
  } else {
3850
+ if (latest != null) {
3851
+ leaveWaitingCall(segment, CHANNEL_OF[property], latest);
3852
+ }
3685
3853
  replace({
3686
3854
  ...segment,
3687
3855
  target: segment.target + delta,
@@ -0,0 +1,138 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file paging.ts
5
+ * @input A release velocity, a settle's start, end and velocity, and the UIKit
6
+ * paging constants
7
+ * @output Exports `pagingSettleVelocity`, `solvePaging` (UIKit's paging settle
8
+ * as a curve the animator plays) and `solveAnimatedPageScroll`
9
+ * @position Pure math, with no DOM and no React
10
+ *
11
+ * SYNC: When modified, update:
12
+ * - /packages/core/src/motion/paging.test.ts
13
+ * - /packages/core/src/motion/README.md
14
+ */
15
+
16
+ import type {MotionCurve} from './animator';
17
+ import {paging} from './uikit/paging';
18
+
19
+ /**
20
+ * The velocity a paging settle starts at. It is the release velocity that every
21
+ * gesture reads, zeroed below `velocityFloor`. The page choice reads the
22
+ * release velocity itself.
23
+ */
24
+ export function pagingSettleVelocity(velocity: number): number {
25
+ return Math.abs(velocity) < paging.release.velocityFloor ? 0 : velocity;
26
+ }
27
+
28
+ /** A paging settle: the curve and where it ends. */
29
+ export interface PagingCurve extends MotionCurve {
30
+ readonly from: number;
31
+ readonly to: number;
32
+ /** Integration steps the settle took. */
33
+ readonly steps: number;
34
+ }
35
+
36
+ /**
37
+ * UIKit's paging settle from `from` (px), moving at `velocity` (px/s), onto
38
+ * `to`. It steps `paging.integrator` `stepsPerSecond` times a second and ends
39
+ * exactly on `to`. The same motion returns an overscrolled pager to its first
40
+ * or last page.
41
+ *
42
+ * Positions are content offsets along the paging axis, 0 at page 0 and growing
43
+ * toward later pages. Velocities are px/s of that offset.
44
+ *
45
+ * No closed form reproduces the per-frame stepping, so the settle integrates
46
+ * once into a table, and the curve reads the table. A release needs at most
47
+ * about 400 steps, well under a millisecond of work.
48
+ *
49
+ * The release velocity enters in px per millisecond per step, as UIKit hands it
50
+ * to the integrator. At 960 steps a second, the content starts at 96 % of the
51
+ * finger's speed, as in UIKit.
52
+ */
53
+ export function solvePaging({
54
+ from,
55
+ velocity,
56
+ to,
57
+ }: {
58
+ from: number;
59
+ velocity: number;
60
+ to: number;
61
+ }): PagingCurve {
62
+ const {springPull, friction, stepsPerSecond, restDistance, restSpeed} =
63
+ paging.integrator;
64
+ const xs: number[] = [from];
65
+ const vs: number[] = [velocity / 1000];
66
+ let x = from;
67
+ let v = velocity / 1000;
68
+ // ~10 s of steps: far beyond any settle (≈ 0.35 s from rest).
69
+ const maxSteps = stepsPerSecond * 10;
70
+ let steps = 0;
71
+ while (steps < maxSteps) {
72
+ if (Math.abs(to - x) < restDistance && Math.abs(v) < restSpeed) {
73
+ break;
74
+ }
75
+ v = (v + (to - x) * springPull) * friction;
76
+ x += v;
77
+ steps += 1;
78
+ xs.push(x);
79
+ vs.push(v);
80
+ }
81
+ xs[xs.length - 1] = to;
82
+ vs[vs.length - 1] = 0;
83
+ const duration = steps / stepsPerSecond;
84
+ const last = xs.length - 1;
85
+ const read = (table: number[], t: number): number => {
86
+ if (!(t > 0)) {
87
+ return table[0];
88
+ }
89
+ const s = t * stepsPerSecond;
90
+ if (s >= last) {
91
+ return table[last];
92
+ }
93
+ const i = Math.floor(s);
94
+ const f = s - i;
95
+ return table[i] + (table[i + 1] - table[i]) * f;
96
+ };
97
+ return {
98
+ from,
99
+ to,
100
+ steps,
101
+ duration,
102
+ at: t => read(xs, t),
103
+ // px per step × steps per second
104
+ velocity: t => read(vs, t) * stepsPerSecond,
105
+ };
106
+ }
107
+
108
+ /**
109
+ * `setContentOffset(_:animated:)`: `paging.animatedScroll.duration` on a
110
+ * sinusoidal ease-in-out, whatever the distance. UIKit starts it from the
111
+ * current offset and drops any velocity, as here.
112
+ */
113
+ export function solveAnimatedPageScroll({
114
+ from,
115
+ to,
116
+ }: {
117
+ from: number;
118
+ to: number;
119
+ }): MotionCurve {
120
+ const {duration} = paging.animatedScroll;
121
+ const distance = to - from;
122
+ return {
123
+ duration,
124
+ at: t => {
125
+ const u = Math.min(1, Math.max(0, t / duration));
126
+ return from + (distance * (1 - Math.cos(Math.PI * u))) / 2;
127
+ },
128
+ velocity: t => {
129
+ if (t <= 0 || t >= duration) {
130
+ return 0;
131
+ }
132
+ return (
133
+ ((distance * Math.PI) / (2 * duration)) *
134
+ Math.sin((Math.PI * t) / duration)
135
+ );
136
+ },
137
+ };
138
+ }
@@ -4,7 +4,7 @@
4
4
  * @file index.ts
5
5
  * @input The domain files of the UIKit constants table
6
6
  * @output Re-exports `springs` and one constant per spring (`SHEET_PRESENT_SPRING`,
7
- * …), `scroll`, `timing`, `navigation` and the
7
+ * …), `scroll`, `timing`, `navigation`, the `slider` domain and the
8
8
  * gesture thresholds (one `UIKIT_*` group per recognizer, their union
9
9
  * `UIKIT_GESTURES`, and the haptic tables)
10
10
  * @position Entry of the UIKit constants table (motion/uikit). Internal to
@@ -40,6 +40,7 @@ export {
40
40
  } from './gestures';
41
41
  export {navigation, registerNavigationTable} from './navigation';
42
42
  export {registerScrollTable, scroll} from './scroll';
43
+ export {slider} from './slider';
43
44
  export {
44
45
  ACTION_SHEET_DISMISS_SPRING,
45
46
  ACTION_SHEET_PRESENT_SPRING,
@@ -0,0 +1,162 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file paging.ts
5
+ * @input Nothing; data only
6
+ * @output Exports `paging`, the measured constants of a paging UIScrollView
7
+ * @position The paging domain of the UIKit constants table (motion/uikit). Only
8
+ * core reads it, so the `uikit` namespace does not re-export it
9
+ *
10
+ * SYNC: When modified, update:
11
+ * - /packages/core/src/motion/MEASUREMENTS.md (measurement tasks)
12
+ * - /packages/core/src/motion/paging.ts and paging.test.ts
13
+ */
14
+
15
+ export const paging = {
16
+ /**
17
+ * After the lift, one model moves the content: the settle onto the chosen
18
+ * page and the return from an overscroll. Each display frame runs floor(frame
19
+ * interval in ms) steps of 1 ms:
20
+ *
21
+ * v = (v + (target − x) · springPull) · friction; x += v (v: pt per step)
22
+ *
23
+ * A 60 Hz frame runs 16 steps, not 16.67. With these values, the model
24
+ * replays eight captured releases to 0.20 pt RMS. It replays five overscroll
25
+ * returns to 0.16–0.33 pt RMS (402 pt pages, iOS 26.2, 3× screen). Without
26
+ * the floor, the error is 1.0 pt RMS, with a systematic shape.
27
+ */
28
+ integrator: {
29
+ /**
30
+ * Velocity added per 1 ms step, per point of distance to the target page
31
+ * (pt per step²).
32
+ * @source measured: UIKit lab, iOS 26.2 simulator, Oct 23 2026.
33
+ * `_pagingSpringPull` reads the same on a 402 pt paging UIScrollView and
34
+ * on UIPageViewController's scroll view. It replays 8 captured settles to
35
+ * 0.20 pt RMS.
36
+ */
37
+ springPull: 0.000284858,
38
+ /**
39
+ * Velocity kept per step after the pull (`_pagingFriction`, equal to
40
+ * `_defaultPagingFriction`).
41
+ * @source measured: UIKit lab, iOS 26.2 simulator, Oct 23 2026. Both read
42
+ * the same on the two scroll views of `springPull`, and they replay with
43
+ * it.
44
+ */
45
+ friction: 0.970815,
46
+ /**
47
+ * Integration steps per second: floor(16.67 ms) = 16 steps per 60 Hz frame,
48
+ * and floor(8.33 ms) = 8 per 120 Hz frame, so 960.
49
+ * @source measured: UIKit lab, iOS 26.2 simulator, Oct 23 2026. In a 60 fps
50
+ * log of 8 releases at rest, floored stepping fits to 0.20 pt RMS.
51
+ * Unfloored stepping fits to 1.0 pt RMS.
52
+ */
53
+ stepsPerSecond: 960,
54
+ /**
55
+ * A settle ends exactly on the page once the remaining distance is below
56
+ * this many points and the step velocity is below `restSpeed`.
57
+ * @source measured: UIKit lab, iOS 26.2 simulator, Oct 23 2026. The
58
+ * captured settles jump the last 0.33 pt onto the page about 0.35 s after
59
+ * a release at rest.
60
+ */
61
+ restDistance: 0.5,
62
+ /**
63
+ * Step velocity (pt per step) below which a settle may end.
64
+ * @source GUESS — the captured settles end at about 0.04 pt per step;
65
+ * the threshold is not observable more finely at 60 fps
66
+ * @measure M-PG12
67
+ */
68
+ restSpeed: 0.05,
69
+ },
70
+ release: {
71
+ /**
72
+ * Released slower than `advanceSpeed`, the scroll view settles on the page
73
+ * nearest the content position: past half a page, the next one.
74
+ * @source measured: UIKit lab, iOS 26.2 simulator, Oct 23 2026. In releases
75
+ * at rest from page 1, content at 48.5 % of the page stays, and 57.5 %
76
+ * goes to the next page. On a second 402 pt pager, 45 % stays and 55 %
77
+ * advances.
78
+ */
79
+ commitFraction: 0.5,
80
+ /**
81
+ * Released at least this fast (pt/s), the scroll view pages in the
82
+ * direction of the velocity, from any content position. A flick from 6 % of
83
+ * the page advances. The speed is UIPanGestureRecognizer's velocity at the
84
+ * last movement, and it never decays. The scroll view's
85
+ * `_horizontalVelocity` equals it at every sample. A drag held still for
86
+ * 397 ms before the lift still pages at the speed it had.
87
+ * @source measured: UIKit lab, iOS 26.2 simulator, Oct 23 2026. Steady
88
+ * releases at 10, 25 and 40 % of a 402 pt page: 292.7, 295.3 and 298.6
89
+ * pt/s stay; 310.7, 316.4 and 329.9 advance. The result is the same at
90
+ * every position: a threshold, not a projection.
91
+ */
92
+ advanceSpeed: 300,
93
+ /**
94
+ * A settle velocity slower than this (pt/s) is zeroed, so the settle starts
95
+ * at rest. It is paging's own tuning on the one release velocity that every
96
+ * gesture reads.
97
+ * @source GUESS — the middle of the measured (200, 249] bracket. In the
98
+ * UIKit lab (iOS 26.2 simulator, Oct 23 2026),
99
+ * `scrollViewWillEndDragging` reads 0 for steady releases at 175 and 200
100
+ * pt/s. It reads 0.249 pt/ms for a release at 249 pt/s.
101
+ * @measure M-PG13
102
+ */
103
+ velocityFloor: 225,
104
+ /**
105
+ * Pages a release may travel from the page the drag started on, at any
106
+ * speed and drag distance.
107
+ * @source measured: UIKit lab, iOS 26.2 simulator, Oct 23 2026. A drag of
108
+ * 1.5 pages from page 1 lands on page 2 at release speeds of 0, 600 and
109
+ * 3000 pt/s.
110
+ */
111
+ maxPagesFromStart: 1,
112
+ },
113
+ /**
114
+ * A finger that lands on a settling pager does not stop it at once. The
115
+ * settle runs on for about this long, then the content holds under the
116
+ * finger. Lifted without a move, the content settles again on the nearest
117
+ * page.
118
+ * @source measured: UIKit lab, iOS 26.2 simulator, Oct 23 2026. After
119
+ * touch-downs 60 and 160 ms into a settle, the content moved on for 82.6
120
+ * and 87.5 ms (5 frames), then held.
121
+ */
122
+ catchStopMs: 85,
123
+ /**
124
+ * A horizontal pager's pan begins only for a drag within atan(1 ÷ this) of
125
+ * horizontal (26.6° for 2). A steeper drag goes to the vertical scroll around
126
+ * it. Between 26.6° and 45°, neither moves.
127
+ * @source measured: UIKit lab, iOS 26.2 simulator, Oct 23 2026. A scroll
128
+ * view's directional lock holds the major axis up to 25° and frees it from
129
+ * 30°. An inline calendar pager in a sheet pages at 20° from horizontal. At
130
+ * 35° and 45°, it moves neither itself nor the sheet, and it leaves 55° to
131
+ * the sheet.
132
+ */
133
+ panAxisRatio: 2,
134
+ animatedScroll: {
135
+ /**
136
+ * `setContentOffset(_:animated:)` duration. It is the same for one page and
137
+ * for five, as for a page control's tap.
138
+ * @source measured: UIKit lab, iOS 26.2 simulator, Oct 23 2026.
139
+ * `_contentOffsetAnimationDuration` reads 0.3, and five animated moves of
140
+ * 1 to 5 pages each last 0.30 s.
141
+ */
142
+ duration: 0.3,
143
+ /**
144
+ * Its curve is a sinusoidal ease-in-out: x = from + (to − from) · (1 − cos
145
+ * πu) ÷ 2, for u = t ÷ duration.
146
+ * @source measured: UIKit lab, iOS 26.2 simulator, Oct 23 2026. The five
147
+ * moves fit the sinusoid to 0.14–0.51 pt RMS, and a 2010 pt move fits to
148
+ * 0.20 pt. The curve easeInEaseOut (0.42, 0, 0.58, 1) misses by 7.7 pt.
149
+ */
150
+ curve: 'sineInOut',
151
+ },
152
+ /**
153
+ * Past the first or last page, the content follows the finger through the
154
+ * scroll view's rubber band (scroll.rubberBand.coefficient), with d = the
155
+ * page width. It returns on the integrator above.
156
+ * @source measured: UIKit lab, iOS 26.2 simulator, Oct 23 2026. A pan of 90
157
+ * pt past page 0 shows 42.7–45 pt, and 290 pt shows 114.3 pt. The formula
158
+ * gives 44.1 and 114.2, with c 0.55 and d 402. The return replays on the
159
+ * integrator to 0.16–0.33 pt RMS.
160
+ */
161
+ rubberBandDimension: 'pageWidth',
162
+ } as const;
@@ -4,8 +4,8 @@
4
4
  * @file scroll.ts
5
5
  * @input Nothing; data only
6
6
  * @output Exports `scroll`, the UIScrollView constants: deceleration rates,
7
- * the rubber-band coefficient, the bounce-back spring, paging and the
8
- * content-touch delay
7
+ * the rubber-band coefficient, the bounce-back spring and the content-touch
8
+ * delay
9
9
  * @position The scroll domain of the UIKit constants table (motion/uikit);
10
10
  * read by the motion math and components
11
11
  *
@@ -82,22 +82,6 @@ export const scroll = {
82
82
  * @measure M-S3
83
83
  */
84
84
  bounce: {duration: 0.5, bounce: 0},
85
- paging: {
86
- /**
87
- * Deceleration rate a paging release is projected with to pick the next
88
- * page.
89
- * @source GUESS — UIScrollView paging behaves like a fast scroll view;
90
- * the rate it uses internally is not documented
91
- * @measure M-PG1
92
- */
93
- projectionRate: 0.99,
94
- /**
95
- * The spring that carries a paging scroll view onto its page.
96
- * @source GUESS — SwiftUI `.smooth` default until measured
97
- * @measure M-PG1
98
- */
99
- snap: {duration: 0.5, bounce: 0},
100
- },
101
85
  /**
102
86
  * `delaysContentTouches`: how long a scroll view holds a touch-down from
103
87
  * its content while it decides whether the touch is a scroll (no highlight