@symbiote-native/angular 0.6.0 → 0.6.1

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 (151) hide show
  1. package/babel-register-composed.cjs +11 -3
  2. package/build/angular/anchor-host-registry.d.ts +2 -0
  3. package/build/angular/anchor-host-registry.js +84 -0
  4. package/build/angular/anchor-host-registry.js.map +1 -0
  5. package/build/angular/components/input-accessory-view/index.d.ts +2 -1
  6. package/build/angular/components/input-accessory-view/index.js +28 -39
  7. package/build/angular/components/input-accessory-view/index.js.map +1 -1
  8. package/build/angular/components/scroll-view/projection.js +70 -48
  9. package/build/angular/components/scroll-view/projection.js.map +1 -1
  10. package/build/angular/components/scroll-view/shared.js +28 -25
  11. package/build/angular/components/scroll-view/shared.js.map +1 -1
  12. package/build/angular/components/scroll-view/sticky-header.d.ts +6 -7
  13. package/build/angular/components/scroll-view/sticky-header.js +82 -65
  14. package/build/angular/components/scroll-view/sticky-header.js.map +1 -1
  15. package/build/angular/components/touchable/index.d.ts +2 -5
  16. package/build/angular/components/touchable/index.js +36 -56
  17. package/build/angular/components/touchable/index.js.map +1 -1
  18. package/build/angular/components/virtualized-list/index.d.ts +5 -20
  19. package/build/angular/components/virtualized-list/index.js +145 -275
  20. package/build/angular/components/virtualized-list/index.js.map +1 -1
  21. package/build/angular/components/virtualized-section-list/index.js +2 -4
  22. package/build/angular/components/virtualized-section-list/index.js.map +1 -1
  23. package/build/angular/index.d.ts +2 -1
  24. package/build/angular/index.js +6 -1
  25. package/build/angular/index.js.map +1 -1
  26. package/build/angular/renderer/index.d.ts +0 -1
  27. package/build/angular/renderer/index.js +5 -60
  28. package/build/angular/renderer/index.js.map +1 -1
  29. package/package.json +4 -3
  30. package/src/__tests__/responder-nested-cd.test.ts +1 -1
  31. package/src/anchor-host-registry.ts +88 -0
  32. package/src/components/input-accessory-view/index.ts +30 -39
  33. package/src/components/scroll-view/projection.ts +76 -46
  34. package/src/components/scroll-view/shared.ts +27 -19
  35. package/src/components/scroll-view/sticky-header.ts +89 -64
  36. package/src/components/touchable/index.ts +52 -66
  37. package/src/components/virtualized-list/index.ts +157 -368
  38. package/src/components/virtualized-section-list/index.ts +6 -3
  39. package/src/index.ts +6 -1
  40. package/src/renderer/index.ts +5 -63
  41. package/src/renderer/renderer.test.ts +2 -1
  42. package/build/bootstrap.d.ts +0 -7
  43. package/build/bootstrap.js +0 -14
  44. package/build/components/activity-indicator/index.android.d.ts +0 -9
  45. package/build/components/activity-indicator/index.android.js +0 -64
  46. package/build/components/activity-indicator/index.d.ts +0 -1
  47. package/build/components/activity-indicator/index.ios.d.ts +0 -5
  48. package/build/components/activity-indicator/index.ios.js +0 -64
  49. package/build/components/activity-indicator/index.js +0 -3
  50. package/build/components/activity-indicator/shared.d.ts +0 -64
  51. package/build/components/activity-indicator/shared.js +0 -424
  52. package/build/components/button.d.ts +0 -60
  53. package/build/components/button.js +0 -456
  54. package/build/components/flat-list/index.d.ts +0 -119
  55. package/build/components/flat-list/index.js +0 -691
  56. package/build/components/image/index.android.d.ts +0 -7
  57. package/build/components/image/index.android.js +0 -88
  58. package/build/components/image/index.d.ts +0 -1
  59. package/build/components/image/index.ios.d.ts +0 -7
  60. package/build/components/image/index.ios.js +0 -88
  61. package/build/components/image/index.js +0 -2
  62. package/build/components/image/shared.d.ts +0 -101
  63. package/build/components/image/shared.js +0 -405
  64. package/build/components/image-background.d.ts +0 -78
  65. package/build/components/image-background.js +0 -583
  66. package/build/components/input-accessory-view/index.d.ts +0 -56
  67. package/build/components/input-accessory-view/index.js +0 -403
  68. package/build/components/keyboard-avoiding-view/index.d.ts +0 -79
  69. package/build/components/keyboard-avoiding-view/index.js +0 -504
  70. package/build/components/modal/index.d.ts +0 -89
  71. package/build/components/modal/index.js +0 -529
  72. package/build/components/pressable/index.d.ts +0 -120
  73. package/build/components/pressable/index.js +0 -639
  74. package/build/components/refresh-control/index.d.ts +0 -83
  75. package/build/components/refresh-control/index.js +0 -509
  76. package/build/components/safe-area-view/index.d.ts +0 -54
  77. package/build/components/safe-area-view/index.js +0 -388
  78. package/build/components/scroll-view/index.android.d.ts +0 -16
  79. package/build/components/scroll-view/index.android.js +0 -228
  80. package/build/components/scroll-view/index.d.ts +0 -3
  81. package/build/components/scroll-view/index.ios.d.ts +0 -6
  82. package/build/components/scroll-view/index.ios.js +0 -113
  83. package/build/components/scroll-view/index.js +0 -5
  84. package/build/components/scroll-view/projection.d.ts +0 -32
  85. package/build/components/scroll-view/projection.js +0 -311
  86. package/build/components/scroll-view/shared.d.ts +0 -225
  87. package/build/components/scroll-view/shared.js +0 -757
  88. package/build/components/scroll-view/sticky-header.d.ts +0 -38
  89. package/build/components/scroll-view/sticky-header.js +0 -257
  90. package/build/components/section-list/index.d.ts +0 -113
  91. package/build/components/section-list/index.js +0 -768
  92. package/build/components/switch/index.android.d.ts +0 -17
  93. package/build/components/switch/index.android.js +0 -107
  94. package/build/components/switch/index.d.ts +0 -1
  95. package/build/components/switch/index.ios.d.ts +0 -16
  96. package/build/components/switch/index.ios.js +0 -106
  97. package/build/components/switch/index.js +0 -3
  98. package/build/components/switch/shared.d.ts +0 -37
  99. package/build/components/switch/shared.js +0 -176
  100. package/build/components/text-input.d.ts +0 -150
  101. package/build/components/text-input.js +0 -821
  102. package/build/components/touchable/index.d.ts +0 -241
  103. package/build/components/touchable/index.js +0 -1672
  104. package/build/components/touchable-native-feedback/index.d.ts +0 -80
  105. package/build/components/touchable-native-feedback/index.js +0 -545
  106. package/build/components/virtualized-list/directives.d.ts +0 -41
  107. package/build/components/virtualized-list/directives.js +0 -249
  108. package/build/components/virtualized-list/index.d.ts +0 -265
  109. package/build/components/virtualized-list/index.js +0 -1343
  110. package/build/components/virtualized-section-list/directives.d.ts +0 -28
  111. package/build/components/virtualized-section-list/directives.js +0 -148
  112. package/build/components/virtualized-section-list/index.d.ts +0 -175
  113. package/build/components/virtualized-section-list/index.js +0 -854
  114. package/build/components.d.ts +0 -39
  115. package/build/components.js +0 -37
  116. package/build/create-portal/index.d.ts +0 -17
  117. package/build/create-portal/index.js +0 -128
  118. package/build/create-tunnel/index.d.ts +0 -29
  119. package/build/create-tunnel/index.js +0 -181
  120. package/build/descriptor-to-angular/index.d.ts +0 -19
  121. package/build/descriptor-to-angular/index.js +0 -186
  122. package/build/host-instance/index.d.ts +0 -2
  123. package/build/host-instance/index.js +0 -36
  124. package/build/index.d.ts +0 -21
  125. package/build/index.js +0 -40
  126. package/build/modules/animated/animated-leaf-binder/index.d.ts +0 -14
  127. package/build/modules/animated/animated-leaf-binder/index.js +0 -90
  128. package/build/modules/animated/animated-leaf-binder.d.ts +0 -14
  129. package/build/modules/animated/animated-leaf-binder.js +0 -90
  130. package/build/modules/animated/create-animated-component.d.ts +0 -48
  131. package/build/modules/animated/create-animated-component.js +0 -425
  132. package/build/modules/animated/index.d.ts +0 -51
  133. package/build/modules/animated/index.js +0 -58
  134. package/build/modules/app-registry/index.d.ts +0 -7
  135. package/build/modules/app-registry/index.js +0 -22
  136. package/build/modules/status-bar/index.d.ts +0 -17
  137. package/build/modules/status-bar/index.js +0 -121
  138. package/build/primitives/index.d.ts +0 -32
  139. package/build/primitives/index.js +0 -386
  140. package/build/primitives/shared.d.ts +0 -83
  141. package/build/primitives/shared.js +0 -230
  142. package/build/render/index.d.ts +0 -11
  143. package/build/render/index.js +0 -170
  144. package/build/renderer/index.d.ts +0 -38
  145. package/build/renderer/index.js +0 -355
  146. package/build/services/color-scheme.service.d.ts +0 -13
  147. package/build/services/color-scheme.service.js +0 -72
  148. package/build/services/index.d.ts +0 -2
  149. package/build/services/index.js +0 -2
  150. package/build/services/window-dimensions.service.d.ts +0 -13
  151. package/build/services/window-dimensions.service.js +0 -83
@@ -15,10 +15,13 @@ import {
15
15
  type ISymbioteNode,
16
16
  } from '@symbiote-native/engine';
17
17
  import {
18
- computeStickyInterpolation,
18
+ createInitialStickyState,
19
19
  readLayoutNumber,
20
- stickyDebounceMs,
20
+ reduceSticky,
21
21
  STICKY_HEADER_Z_INDEX,
22
+ type IStickyAction,
23
+ type IStickyEffect,
24
+ type IStickyReducerInputs,
22
25
  } from '@symbiote-native/components';
23
26
  import { descriptorFor } from '@symbiote-native/components';
24
27
  import { Platform } from '@symbiote-native/engine';
@@ -64,17 +67,20 @@ function isProjectedRefreshControl(node: ISymbioteNode): boolean {
64
67
  return isAnchor(node) && node.children.some(isProjectedRefreshControl);
65
68
  }
66
69
 
70
+ // The SECOND Angular sticky effect-runner (auto-projected children can't be arbitrary Angular
71
+ // component classes, so the controller drives engine nodes directly). It shares the ONE
72
+ // reduceSticky state machine with ScrollViewStickyHeader — the DECISIONS (zero-swallow gate, debounce
73
+ // delay, rebuild ranges, cross-talk record) live there; this runner only EXECUTES the effects on the
74
+ // engine node (build interpolation + wire listener, hold the debounce timer, re-apply the node props).
75
+ // Because it owns the child index, it consumes the reducer's record-header-y effect for cross-talk.
67
76
  class StickyProjectionWrapper {
68
- private measured = false;
69
- private layoutY = 0;
70
- private layoutHeight = 0;
77
+ private readonly state = createInitialStickyState();
71
78
  private animatedTranslateY: AnimatedInterpolation;
79
+ private interpolation: AnimatedInterpolation | undefined;
72
80
  private animatedProps: AnimatedProps | undefined;
73
81
  private cancelBind: (() => void) | undefined;
74
82
  private listenerId: string | undefined;
75
83
  private debounceTimer: ReturnType<typeof setTimeout> | undefined;
76
- private translateY: number | null = null;
77
- private haveReceivedInitialZeroTranslateY = true;
78
84
 
79
85
  constructor(
80
86
  private readonly controller: ScrollViewProjectionController,
@@ -85,14 +91,14 @@ class StickyProjectionWrapper {
85
91
  inputRange: [-1, 0],
86
92
  outputRange: [0, 0],
87
93
  });
88
- this.rebuild();
94
+ this.dispatch({ kind: 'inputs-changed' });
89
95
  }
90
96
 
91
97
  destroy(): void {
92
98
  this.cancelBind?.();
93
99
  this.cancelBind = undefined;
94
- if (this.listenerId !== undefined) {
95
- this.animatedTranslateY.removeListener(this.listenerId);
100
+ if (this.interpolation !== undefined && this.listenerId !== undefined) {
101
+ this.interpolation.removeListener(this.listenerId);
96
102
  this.listenerId = undefined;
97
103
  }
98
104
  if (this.debounceTimer !== undefined) {
@@ -105,53 +111,77 @@ class StickyProjectionWrapper {
105
111
  }
106
112
  }
107
113
 
114
+ // The controller calls this when a cross-talk input changed (a sibling recorded its y): rebuild the
115
+ // ranges off the new nextStickyHeaderY.
108
116
  rebuild(): void {
109
- if (this.listenerId !== undefined) {
110
- this.animatedTranslateY.removeListener(this.listenerId);
111
- this.listenerId = undefined;
112
- }
117
+ this.dispatch({ kind: 'inputs-changed' });
118
+ }
113
119
 
114
- const { inputRange, outputRange } = computeStickyInterpolation({
115
- measured: this.measured,
120
+ private inputs(): IStickyReducerInputs {
121
+ return {
122
+ os: Platform.OS,
116
123
  inverted: this.controller.config.invertStickyHeaders,
117
124
  scrollViewHeight: this.controller.config.scrollViewHeight,
118
- layoutY: this.layoutY,
119
- layoutHeight: this.layoutHeight,
120
125
  nextHeaderLayoutY: this.controller.nextStickyHeaderY(this.childIndex),
121
- });
122
- this.animatedTranslateY = this.controller.config.scrollAnimatedValue.interpolate({
123
- inputRange,
124
- outputRange,
125
- });
126
- this.listenerId = this.animatedTranslateY.addListener(this.animatedValueListener);
127
- this.applyProps();
128
- dlog(
129
- `Angular ScrollView projection sticky index=${this.childIndex} measured=${this.measured} y=${this.layoutY}`,
130
- );
126
+ index: this.childIndex,
127
+ };
128
+ }
129
+
130
+ private dispatch(action: IStickyAction): void {
131
+ this.runEffects(reduceSticky(this.state, action, this.inputs()).effects);
132
+ }
133
+
134
+ private runEffects(effects: IStickyEffect[]): void {
135
+ for (const effect of effects) {
136
+ switch (effect.kind) {
137
+ case 'rebuild-interpolation': {
138
+ if (this.interpolation !== undefined && this.listenerId !== undefined) {
139
+ this.interpolation.removeListener(this.listenerId);
140
+ this.listenerId = undefined;
141
+ }
142
+ const next = this.controller.config.scrollAnimatedValue.interpolate({
143
+ inputRange: effect.inputRange,
144
+ outputRange: effect.outputRange,
145
+ });
146
+ this.listenerId = next.addListener(this.animatedValueListener);
147
+ this.interpolation = next;
148
+ this.animatedTranslateY = next;
149
+ this.applyProps();
150
+ dlog(
151
+ `Angular ScrollView projection sticky index=${this.childIndex} measured=${this.state.measured} y=${this.state.layoutY}`,
152
+ );
153
+ break;
154
+ }
155
+ case 'schedule-debounce':
156
+ if (this.debounceTimer !== undefined) clearTimeout(this.debounceTimer);
157
+ this.debounceTimer = setTimeout(() => {
158
+ this.debounceTimer = undefined;
159
+ this.dispatch({ kind: 'debounce-fired', value: effect.value });
160
+ }, effect.delay);
161
+ break;
162
+ case 'apply-passthrough':
163
+ this.applyProps();
164
+ break;
165
+ case 'record-header-y':
166
+ this.controller.recordHeaderLayoutY(effect.index, effect.y);
167
+ break;
168
+ }
169
+ }
131
170
  }
132
171
 
133
172
  private readonly onLayout = (event: ISymbioteEvent): void => {
134
173
  const y = readLayoutNumber(event, 'y');
135
174
  const height = readLayoutNumber(event, 'height');
136
- if (y !== undefined) this.layoutY = y;
137
- if (height !== undefined) this.layoutHeight = height;
138
- this.measured = true;
139
- this.controller.recordHeaderLayoutY(this.childIndex, this.layoutY);
140
- this.rebuild();
175
+ // Keep the previous value when a field is absent (RN sets state only on a defined read).
176
+ this.dispatch({
177
+ kind: 'layout',
178
+ y: y ?? this.state.layoutY,
179
+ height: height ?? this.state.layoutHeight,
180
+ });
141
181
  };
142
182
 
143
183
  private readonly animatedValueListener = ({ value }: { value: number | string }): void => {
144
- if (typeof value !== 'number') return;
145
- if (value === 0 && !this.haveReceivedInitialZeroTranslateY) {
146
- this.haveReceivedInitialZeroTranslateY = true;
147
- return;
148
- }
149
- if (this.debounceTimer !== undefined) clearTimeout(this.debounceTimer);
150
- this.debounceTimer = setTimeout(() => {
151
- this.translateY = value;
152
- if (value !== 0) this.haveReceivedInitialZeroTranslateY = false;
153
- this.applyProps();
154
- }, stickyDebounceMs(Platform.OS));
184
+ if (typeof value === 'number') this.dispatch({ kind: 'animated-tick', value });
155
185
  };
156
186
 
157
187
  private props(): Record<string, unknown> {
@@ -160,9 +190,9 @@ class StickyProjectionWrapper {
160
190
  zIndex: STICKY_HEADER_Z_INDEX,
161
191
  };
162
192
  const passthrough =
163
- this.translateY === null
193
+ this.state.translateY === null
164
194
  ? undefined
165
- : { transform: [{ translateY: this.translateY }], zIndex: STICKY_HEADER_Z_INDEX };
195
+ : { transform: [{ translateY: this.state.translateY }], zIndex: STICKY_HEADER_Z_INDEX };
166
196
  return {
167
197
  style: passthrough === undefined ? style : [style, passthrough],
168
198
  onLayout: this.onLayout,
@@ -38,6 +38,7 @@ import {
38
38
  readLayoutDimension,
39
39
  resolveAccessibilityProps,
40
40
  resolveDecelerationRate,
41
+ resolveScrollForwarding,
41
42
  selectScrollIntrinsics,
42
43
  splitLayoutProps,
43
44
  type IAccessibilityProps,
@@ -566,27 +567,34 @@ export abstract class ScrollViewBase implements IAngularScrollViewInputs, AfterV
566
567
  // by the post-commit attach below, so onScroll forwards to the user with throttle 1
567
568
  // (ScrollView.js:1798). Without it, Animated.event drives the value each frame (the JS jitter)
568
569
  // and forwards the user handler as the listener passthrough.
570
+ // The scroll-forwarding DECISIONS (which onScroll path, the 1/16 throttle defaults, whether to
571
+ // capture the viewport height) are folded out to the shared resolveScrollForwarding; here Angular
572
+ // only EXECUTES them, keeping its stable-reference handlers (stickyOnScrollHandler /
573
+ // handleInvertedStickyLayout / emitterCallback) so a fresh closure never forces a re-clone cascade.
569
574
  const nativeStickyAvailable = this.hasStickyHeaders && isNativeAnimatedAvailable();
570
- if (this.hasStickyHeaders) {
571
- if (nativeStickyAvailable) {
572
- if (this.onScroll !== undefined) bag.onScroll = this.onScroll;
573
- bag.scrollEventThrottle = this.scrollEventThrottle ?? 1;
574
- } else {
575
- bag.onScroll = this.stickyOnScrollHandler(this.onScroll);
576
- bag.scrollEventThrottle = this.scrollEventThrottle ?? 16;
577
- }
578
- // Inverted sticky headers need the viewport height (RN _handleLayout): capture it on the
579
- // scroll-view onLayout, then call the user's handler. Non-inverted forwards onLayout as-is.
580
- if (this.invertStickyHeaders === true) {
581
- bag.onLayout = this.handleInvertedStickyLayout;
582
- } else {
583
- const layoutCallback = this.emitterCallback(this.layout);
584
- if (layoutCallback !== undefined) bag.onLayout = layoutCallback;
585
- }
575
+ const forwarding = resolveScrollForwarding({
576
+ hasStickyHeaders: this.hasStickyHeaders,
577
+ nativeStickyAvailable,
578
+ invertStickyHeaders: this.invertStickyHeaders,
579
+ scrollEventThrottle: this.scrollEventThrottle,
580
+ maintainVisibleContentPosition: this.maintainVisibleContentPosition,
581
+ snapToAlignment: this.snapToAlignment,
582
+ });
583
+ // onScroll: the JS-fallback path uses the cached Animated.event handler; the native + plain paths
584
+ // forward the user handler as-is (the native driver attaches the value on the UI thread).
585
+ if (forwarding.mode === 'sticky-js') {
586
+ bag.onScroll = this.stickyOnScrollHandler(this.onScroll);
587
+ } else if (this.onScroll !== undefined) {
588
+ bag.onScroll = this.onScroll;
589
+ }
590
+ if (forwarding.scrollEventThrottle !== undefined) {
591
+ bag.scrollEventThrottle = forwarding.scrollEventThrottle;
592
+ }
593
+ // Inverted sticky headers need the viewport height (RN _handleLayout): capture it on the
594
+ // scroll-view onLayout, then call the user's handler. Otherwise forward the layout emitter.
595
+ if (forwarding.capturesViewportHeight) {
596
+ bag.onLayout = this.handleInvertedStickyLayout;
586
597
  } else {
587
- if (this.onScroll !== undefined) bag.onScroll = this.onScroll;
588
- if (this.scrollEventThrottle !== undefined)
589
- bag.scrollEventThrottle = this.scrollEventThrottle;
590
598
  const layoutCallback = this.emitterCallback(this.layout);
591
599
  if (layoutCallback !== undefined) bag.onLayout = layoutCallback;
592
600
  }
@@ -37,11 +37,14 @@ import {
37
37
  type ISymbioteEvent,
38
38
  } from '@symbiote-native/engine';
39
39
  import {
40
- computeStickyInterpolation,
40
+ createInitialStickyState,
41
41
  readLayoutNumber,
42
- stickyDebounceMs,
42
+ reduceSticky,
43
43
  STICKY_HEADER_Z_INDEX,
44
+ type IStickyAction,
45
+ type IStickyEffect,
44
46
  type IStickyHeaderProps,
47
+ type IStickyReducerInputs,
45
48
  } from '@symbiote-native/components';
46
49
  import { AnimatedView } from '../../modules/animated';
47
50
  import { anchorHostStyle } from '../../primitives';
@@ -90,19 +93,21 @@ export class ScrollViewStickyHeader
90
93
  // previous header learns its collision point. Called after the header measures.
91
94
  @Output() readonly layout = new EventEmitter<ISymbioteEvent>();
92
95
 
93
- private measured = false;
94
- private layoutY = 0;
95
- private layoutHeight = 0;
96
- // The animated node driving the transform (RN's animatedTranslateY). When the scroll value is
97
- // native (attachStickyScroll), this interpolation runs on the UI thread: the smooth pin.
96
+ // The one folded state cell, mutated in place by reduceSticky. The DECISIONS — zero-swallow gate,
97
+ // debounce delay, rebuild ranges — all live there; this component supplies only the Angular
98
+ // lifecycle (inputs + manual change detection), the interpolation-node + listener wiring, the
99
+ // debounce setTimeout, and the markForCheck re-render.
100
+ private readonly state = createInitialStickyState();
101
+ // The animated node driving the transform (RN's animatedTranslateY), replaced by the
102
+ // rebuild-interpolation effect. When the scroll value is native, this interpolation runs on the UI
103
+ // thread: the smooth pin.
98
104
  private animatedTranslateY: AnimatedInterpolation = new AnimatedValue(0).interpolate({
99
105
  inputRange: [-1, 0],
100
106
  outputRange: [0, 0],
101
107
  });
102
- // The debounced EXPLICIT translateY pushed to the committed transform via the passthrough, so the
103
- // Fabric ShadowTree (hit-testing) knows the pinned position while the native driver animates.
104
- private translateY: number | null = null;
105
- private haveReceivedInitialZeroTranslateY = true;
108
+ // The current interpolation node + its listener id, held so the next rebuild detaches the old
109
+ // listener (an engine call the reducer does not own) and ngOnDestroy cleans up.
110
+ private interpolation: AnimatedInterpolation | undefined;
106
111
  private debounceTimer: ReturnType<typeof setTimeout> | undefined;
107
112
  private listenerId: string | undefined;
108
113
 
@@ -113,21 +118,17 @@ export class ScrollViewStickyHeader
113
118
  private readonly elementRef = inject(ElementRef);
114
119
 
115
120
  ngOnInit(): void {
116
- this.animatedTranslateY = this.scrollAnimatedValue.interpolate({
117
- inputRange: [-1, 0],
118
- outputRange: [0, 0],
119
- });
120
- this.rebuildInterpolation();
121
+ this.dispatch({ kind: 'inputs-changed' });
121
122
  }
122
123
 
123
124
  // Rebuild on any layout/collision input change (RN's effect deps: measured, layoutY, layoutHeight,
124
125
  // scrollViewHeight, nextHeaderLayoutY, inverted). scrollAnimatedValue is stable for one ScrollView.
125
126
  ngOnChanges(): void {
126
- this.rebuildInterpolation();
127
+ this.dispatch({ kind: 'inputs-changed' });
127
128
  }
128
129
 
129
130
  ngOnDestroy(): void {
130
- this.teardownInterpolation();
131
+ this.teardown();
131
132
  }
132
133
 
133
134
  get headerStyle(): unknown {
@@ -161,65 +162,89 @@ export class ScrollViewStickyHeader
161
162
  // The EXPLICIT debounced translateY overrides the committed transform for hit-testing while
162
163
  // animatedTranslateY does the smooth (native-driven) pin (RN ScrollViewStickyHeader.js).
163
164
  get passthrough(): { style: { transform: Array<{ translateY: number }> } } | null {
164
- return this.translateY !== null
165
- ? { style: { transform: [{ translateY: this.translateY }] } }
165
+ return this.state.translateY !== null
166
+ ? { style: { transform: [{ translateY: this.state.translateY }] } }
166
167
  : null;
167
168
  }
168
169
 
169
- // Record own y/height, mark measured, rebuild the interpolation, then fire the parent's recorder
170
- // (RN ScrollViewStickyHeader.js._onLayout order). The wrapped child keeps its own onLayout binding
171
- // (it is projected with its bindings intact), so unlike React/Vue we do not forward to it here.
170
+ private inputs(): IStickyReducerInputs {
171
+ return {
172
+ os: Platform.OS,
173
+ inverted: this.inverted,
174
+ scrollViewHeight: this.scrollViewHeight,
175
+ nextHeaderLayoutY: this.nextHeaderLayoutY,
176
+ };
177
+ }
178
+
179
+ private dispatch(action: IStickyAction): void {
180
+ this.runEffects(reduceSticky(this.state, action, this.inputs()).effects);
181
+ }
182
+
183
+ private runEffects(effects: IStickyEffect[]): void {
184
+ for (const effect of effects) {
185
+ switch (effect.kind) {
186
+ case 'rebuild-interpolation': {
187
+ // Detach the old listener, build a fresh interpolation onto the shared scroll value, and
188
+ // wire the settled-value listener (symbiote is always Fabric; RN attaches it only there).
189
+ if (this.interpolation !== undefined && this.listenerId !== undefined) {
190
+ this.interpolation.removeListener(this.listenerId);
191
+ this.listenerId = undefined;
192
+ }
193
+ const next = this.scrollAnimatedValue.interpolate({
194
+ inputRange: effect.inputRange,
195
+ outputRange: effect.outputRange,
196
+ });
197
+ this.listenerId = next.addListener(this.animatedValueListener);
198
+ this.interpolation = next;
199
+ this.animatedTranslateY = next;
200
+ dlog(
201
+ `Angular ScrollViewStickyHeader interpolation measured=${this.state.measured} y=${this.state.layoutY}`,
202
+ );
203
+ break;
204
+ }
205
+ case 'schedule-debounce':
206
+ // The animated value updates several times per frame; debounce the settled value into the
207
+ // committed transform so hit detection stays current (a Fabric issue, worse on Android).
208
+ if (this.debounceTimer !== undefined) clearTimeout(this.debounceTimer);
209
+ this.debounceTimer = setTimeout(() => {
210
+ this.debounceTimer = undefined;
211
+ this.dispatch({ kind: 'debounce-fired', value: effect.value });
212
+ }, effect.delay);
213
+ break;
214
+ case 'apply-passthrough':
215
+ this.changeDetector.markForCheck();
216
+ break;
217
+ case 'record-header-y':
218
+ // The Angular header records through the `layout` EventEmitter (handleLayout below), the
219
+ // public recorder contract; the reducer emits no index for it.
220
+ break;
221
+ }
222
+ }
223
+ }
224
+
225
+ // Record own y/height, mark measured, rebuild the interpolation (all via the reducer), then fire
226
+ // the parent's recorder (RN ScrollViewStickyHeader.js._onLayout order). The wrapped child keeps its
227
+ // own onLayout binding (projected with its bindings intact), so unlike React/Vue we do not forward.
172
228
  private handleLayout(event: ISymbioteEvent): void {
173
229
  const y = readLayoutNumber(event, 'y');
174
230
  const height = readLayoutNumber(event, 'height');
175
- if (y !== undefined) this.layoutY = y;
176
- if (height !== undefined) this.layoutHeight = height;
177
- this.measured = true;
178
- this.rebuildInterpolation();
231
+ // Keep the previous value when a field is absent (RN sets state only on a defined read).
232
+ this.dispatch({
233
+ kind: 'layout',
234
+ y: y ?? this.state.layoutY,
235
+ height: height ?? this.state.layoutHeight,
236
+ });
179
237
  this.layout.emit(event);
180
238
  this.changeDetector.markForCheck();
181
239
  }
182
240
 
183
- // The animated value updates several times per frame during scroll; debounce it and push the
184
- // settled value into the committed transform so hit detection stays current (a Fabric-only issue,
185
- // worse on Android). A freshly-rebuilt interpolation re-emits 0; swallow that first zero (RN).
186
241
  private readonly animatedValueListener = ({ value }: { value: number | string }): void => {
187
- if (typeof value !== 'number') return;
188
- if (value === 0 && !this.haveReceivedInitialZeroTranslateY) {
189
- this.haveReceivedInitialZeroTranslateY = true;
190
- return;
191
- }
192
- if (this.debounceTimer !== undefined) clearTimeout(this.debounceTimer);
193
- this.debounceTimer = setTimeout(() => {
194
- this.translateY = value;
195
- if (value !== 0) this.haveReceivedInitialZeroTranslateY = false;
196
- this.changeDetector.markForCheck();
197
- }, stickyDebounceMs(Platform.OS));
242
+ if (typeof value === 'number') this.dispatch({ kind: 'animated-tick', value });
198
243
  };
199
244
 
200
- private rebuildInterpolation(): void {
201
- this.teardownInterpolation();
202
- const { inputRange, outputRange } = computeStickyInterpolation({
203
- measured: this.measured,
204
- inverted: this.inverted,
205
- scrollViewHeight: this.scrollViewHeight,
206
- layoutY: this.layoutY,
207
- layoutHeight: this.layoutHeight,
208
- nextHeaderLayoutY: this.nextHeaderLayoutY,
209
- });
210
- const interpolation = this.scrollAnimatedValue.interpolate({ inputRange, outputRange });
211
- // symbiote is always Fabric: listen to the settled value to keep the ShadowTree transform
212
- // current for hit-testing (RN attaches this listener only under Fabric).
213
- this.listenerId = interpolation.addListener(this.animatedValueListener);
214
- this.animatedTranslateY = interpolation;
215
- dlog(
216
- `Angular ScrollViewStickyHeader interpolation measured=${this.measured} y=${this.layoutY}`,
217
- );
218
- }
219
-
220
- private teardownInterpolation(): void {
221
- if (this.listenerId !== undefined) {
222
- this.animatedTranslateY.removeListener(this.listenerId);
245
+ private teardown(): void {
246
+ if (this.interpolation !== undefined && this.listenerId !== undefined) {
247
+ this.interpolation.removeListener(this.listenerId);
223
248
  this.listenerId = undefined;
224
249
  }
225
250
  if (this.debounceTimer !== undefined) {
@@ -33,7 +33,9 @@ import {
33
33
  Output,
34
34
  } from '@angular/core';
35
35
  import {
36
- computePressOutWait,
36
+ createTouchableFeedbackHandlers,
37
+ createTouchableFeedbackRuntime,
38
+ highlightPressedStyle,
37
39
  DEFAULT_ACTIVE_OPACITY,
38
40
  DEFAULT_HIGHLIGHT_CHILD_OPACITY,
39
41
  DEFAULT_MIN_PRESS_DURATION_MS,
@@ -48,13 +50,9 @@ import {
48
50
  type IPressState,
49
51
  type IPressTimingProps,
50
52
  type IRectOffset,
53
+ type ITouchableFeedbackHandlers,
51
54
  } from '@symbiote-native/components';
52
- import {
53
- dlog,
54
- type ISymbioteEvent,
55
- type IStyleProp,
56
- type IViewStyle,
57
- } from '@symbiote-native/engine';
55
+ import { type ISymbioteEvent, type IStyleProp, type IViewStyle } from '@symbiote-native/engine';
58
56
  import { anchorHostStyle, anchorStyleProp } from '../../primitives';
59
57
  import { Pressable, type IAngularPressableInputs } from '../pressable';
60
58
  import { Animated, AnimatedView } from '../../modules/animated';
@@ -80,6 +78,13 @@ export type IAngularTouchableHighlightProps = IAngularTouchableBaseProps & {
80
78
 
81
79
  export type IAngularTouchableWithoutFeedbackProps = IAngularTouchableBaseProps;
82
80
 
81
+ // The real setTimeout the shared feedback machine schedules its deferred activation/deactivation on
82
+ // (core/components has no timer globals). Returns a canceller so an early release flushes the timer.
83
+ function scheduleTimeout(callback: () => void, ms: number): () => void {
84
+ const id = setTimeout(callback, ms);
85
+ return () => clearTimeout(id);
86
+ }
87
+
83
88
  @Component({
84
89
  selector: 'TouchableOpacity',
85
90
  standalone: true,
@@ -232,10 +237,9 @@ export class TouchableOpacity implements IAngularTouchableOpacityProps {
232
237
  // engine object, never an @Input / reactive wrap); the <symbiote-animated-view> leaf rasterizes
233
238
  // it for the first paint and drives it through setNativeProps every frame.
234
239
  private readonly opacity = new Animated.Value(RESTING_OPACITY);
235
- // The pending delayPressIn timer, so a release before it fires can flush it.
236
- private pressInTimer: ReturnType<typeof setTimeout> | undefined;
237
- // When the active visual actually started, to floor onPressOut by minPressDuration.
238
- private activatedAt: number | undefined;
240
+ // The shared press-scheduling cell (delayPressIn timer + activation clock), persisted on the
241
+ // instance; the machine's handlers are rebuilt per event over live @Input()s.
242
+ private readonly runtime = createTouchableFeedbackRuntime();
239
243
  // This component's OWN host — the non-painting anchor `class="..."` at the use site resolves
240
244
  // onto (see anchorHostStyle's doc comment) — NOT the <symbiote-animated-view> leaf one level down.
241
245
  private readonly elementRef = inject(ElementRef);
@@ -257,61 +261,43 @@ export class TouchableOpacity implements IAngularTouchableOpacityProps {
257
261
  }).start();
258
262
  }
259
263
 
260
- private clearPressInTimer(): void {
261
- if (this.pressInTimer !== undefined) {
262
- clearTimeout(this.pressInTimer);
263
- this.pressInTimer = undefined;
264
- }
265
- }
266
-
267
- private activate(event: ISymbioteEvent): void {
268
- this.activatedAt = Date.now();
269
- this.setOpacityTo(this.activeOpacity ?? DEFAULT_ACTIVE_OPACITY, OPACITY_ACTIVE_DURATION_MS);
270
- this.pressIn.emit(event);
271
- }
272
-
273
- private deactivate(event: ISymbioteEvent): void {
274
- this.activatedAt = undefined;
275
- this.setOpacityTo(RESTING_OPACITY, OPACITY_INACTIVE_DURATION_MS);
276
- this.pressOut.emit(event);
264
+ // Built per event so the machine reads live @Input()s (delay/opacity); the runtime persists across
265
+ // calls. The shared machine owns the scheduling — the adapter supplies only the native seam: the
266
+ // Animated opacity fade + the @Output() emit, as activate/deactivate.
267
+ private feedbackHandlers(): ITouchableFeedbackHandlers {
268
+ return createTouchableFeedbackHandlers(
269
+ {
270
+ delayPressIn: this.delayPressIn ?? 0,
271
+ delayPressOut: this.delayPressOut ?? 0,
272
+ minPressDuration: this.minPressDuration ?? DEFAULT_MIN_PRESS_DURATION_MS,
273
+ schedule: scheduleTimeout,
274
+ now: Date.now,
275
+ },
276
+ this.runtime,
277
+ {
278
+ activate: (event: ISymbioteEvent): void => {
279
+ this.setOpacityTo(
280
+ this.activeOpacity ?? DEFAULT_ACTIVE_OPACITY,
281
+ OPACITY_ACTIVE_DURATION_MS,
282
+ );
283
+ this.pressIn.emit(event);
284
+ },
285
+ deactivate: (event: ISymbioteEvent): void => {
286
+ this.setOpacityTo(RESTING_OPACITY, OPACITY_INACTIVE_DURATION_MS);
287
+ this.pressOut.emit(event);
288
+ },
289
+ },
290
+ );
277
291
  }
278
292
 
279
293
  // Arrow fields (stable identity for OnPush, `this` intact when Pressable's `(pressIn)` invokes
280
- // them). RN's _createPressabilityConfig forwards delayPressIn defer the active visual and
281
- // pressIn behind the delay (a release before it elapses flushes it synchronously).
294
+ // them). The delayPressIn defer + minPressDuration/delayPressOut hold live in the shared machine.
282
295
  handlePressIn = (event: ISymbioteEvent): void => {
283
- const delayPressIn = this.delayPressIn ?? 0;
284
- if (delayPressIn > 0) {
285
- dlog(`TouchableOpacity pressIn deferred ${delayPressIn}ms`);
286
- this.pressInTimer = setTimeout(() => {
287
- this.pressInTimer = undefined;
288
- this.activate(event);
289
- }, delayPressIn);
290
- return;
291
- }
292
- this.activate(event);
296
+ this.feedbackHandlers().handlePressIn(event);
293
297
  };
294
298
 
295
- // delayPressOut + minPressDuration (RN _deactivate): the press-out waits at least minPressDuration
296
- // past activation (so a fast tap holds the active visual) and at least delayPressOut, whichever is
297
- // longer.
298
299
  handlePressOut = (event: ISymbioteEvent): void => {
299
- if (this.pressInTimer !== undefined) {
300
- this.clearPressInTimer();
301
- this.activate(event);
302
- }
303
- const heldFor = this.activatedAt === undefined ? 0 : Date.now() - this.activatedAt;
304
- const wait = computePressOutWait(
305
- heldFor,
306
- this.minPressDuration ?? DEFAULT_MIN_PRESS_DURATION_MS,
307
- this.delayPressOut ?? 0,
308
- );
309
- if (wait > 0) {
310
- dlog(`TouchableOpacity pressOut deferred ${wait}ms`);
311
- setTimeout(() => this.deactivate(event), wait);
312
- return;
313
- }
314
- this.deactivate(event);
300
+ this.feedbackHandlers().handlePressOut(event);
315
301
  };
316
302
  }
317
303
 
@@ -473,13 +459,13 @@ export class TouchableHighlight implements IAngularTouchableHighlightProps {
473
459
  // child opacity (RN drives this with setState, not Animated, so we mirror that faithfully
474
460
  // through Pressable's pressed flag, no tween) — the overlay always goes last so it wins.
475
461
  pressedStyle = (state: IPressState): IStyleProp<IViewStyle> => {
476
- const anchorStyle = anchorStyleProp<IViewStyle>(this.elementRef);
477
- if (!state.pressed) return [anchorStyle, this.style];
478
- const overlay: IViewStyle = {
479
- backgroundColor: this.underlayColor ?? DEFAULT_UNDERLAY_COLOR,
480
- opacity: this.activeOpacity ?? DEFAULT_HIGHLIGHT_CHILD_OPACITY,
481
- };
482
- return [anchorStyle, this.style, overlay];
462
+ const base: IStyleProp<IViewStyle> = [anchorStyleProp<IViewStyle>(this.elementRef), this.style];
463
+ return highlightPressedStyle(
464
+ state.pressed,
465
+ base,
466
+ this.underlayColor ?? DEFAULT_UNDERLAY_COLOR,
467
+ this.activeOpacity ?? DEFAULT_HIGHLIGHT_CHILD_OPACITY,
468
+ );
483
469
  };
484
470
  }
485
471