@octane-xplat/keyboard 0.0.1 → 0.17.0

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 (56) hide show
  1. package/README.md +153 -1
  2. package/UPSTREAM.md +12 -0
  3. package/package.json +77 -9
  4. package/src/KeyboardAwareScrollView.mobile.tsrx +54 -0
  5. package/src/KeyboardAwareScrollView.tsrx +53 -0
  6. package/src/KeyboardStickyView.mobile.tsrx +66 -0
  7. package/src/KeyboardStickyView.tsrx +9 -0
  8. package/src/Provider.tsrx +11 -0
  9. package/src/binding-types.ts +16 -0
  10. package/src/controller.ts +243 -0
  11. package/src/focus-clock.macos.ts +13 -0
  12. package/src/focus-geometry.ts +34 -0
  13. package/src/focus-publish.ts +14 -0
  14. package/src/focus-runtime-types.ts +9 -0
  15. package/src/focus-runtime.macos.ts +62 -0
  16. package/src/focus-runtime.mobile.ts +57 -0
  17. package/src/focus-runtime.ts +14 -0
  18. package/src/focus-runtime.web.ts +139 -0
  19. package/src/focus-source.android.ts +173 -0
  20. package/src/focus-source.ios.ts +202 -0
  21. package/src/focus-source.macos.ts +113 -0
  22. package/src/focus-source.ts +12 -0
  23. package/src/focus-source.web.ts +157 -0
  24. package/src/focus-types.ts +19 -0
  25. package/src/focused-input.ts +43 -0
  26. package/src/index.ts +12 -0
  27. package/src/motion.ts +82 -0
  28. package/src/native-scope.ts +28 -0
  29. package/src/prediction.ts +32 -0
  30. package/src/props.ts +37 -0
  31. package/src/runtime-types.ts +10 -0
  32. package/src/runtime.mobile.ts +62 -0
  33. package/src/runtime.ts +16 -0
  34. package/src/scroll-controller.ts +136 -0
  35. package/src/scroll-geometry.ts +72 -0
  36. package/src/scroll-runtime-types.ts +6 -0
  37. package/src/scroll-runtime.macos.ts +41 -0
  38. package/src/scroll-runtime.mobile.ts +46 -0
  39. package/src/scroll-runtime.ts +7 -0
  40. package/src/scroll-runtime.web.ts +25 -0
  41. package/src/scroll-types.ts +24 -0
  42. package/src/source.android.ts +179 -0
  43. package/src/source.ios.ts +283 -0
  44. package/src/source.ts +5 -0
  45. package/src/tsconfig.json +1 -0
  46. package/src/types.ts +45 -0
  47. package/src/viewport-types.ts +7 -0
  48. package/src/viewport.android.ts +238 -0
  49. package/src/viewport.ios.ts +6 -0
  50. package/src/viewport.ts +6 -0
  51. package/types/focus-types.d.ts +17 -0
  52. package/types/index.d.ts +20 -0
  53. package/types/props.d.ts +35 -0
  54. package/types/scroll-types.d.ts +22 -0
  55. package/types/types.d.ts +40 -0
  56. package/index.js +0 -1
@@ -0,0 +1,43 @@
1
+ import { focusRuntime } from './focus-runtime'
2
+ import type { FocusedInputService } from './focus-types'
3
+
4
+ /** Observe input/caret layout without collecting text or owning editing delegates.
5
+ * @example
6
+ * ```ts
7
+ * import { FocusedInputController } from '@octane-xplat/keyboard'
8
+ * function observe(host: object) {
9
+ * return FocusedInputController.subscribe(host, state => console.log(state.inputRect))
10
+ * }
11
+ * ```
12
+ */
13
+ export const FocusedInputController: FocusedInputService = {
14
+ getSnapshot: (host) => focusRuntime.getSnapshot(host),
15
+ recheck: (host) => focusRuntime.recheck(host),
16
+ subscribe(host, listener) {
17
+ const deactivate = focusRuntime.activate()
18
+ let active = true
19
+ let off: () => void
20
+ try {
21
+ off = focusRuntime.subscribe(host, (state) => {
22
+ if (active) {
23
+ listener(state)
24
+ }
25
+ })
26
+ } catch (error) {
27
+ active = false
28
+ deactivate()
29
+ throw error
30
+ }
31
+ return () => {
32
+ if (!active) {
33
+ return
34
+ }
35
+ active = false
36
+ try {
37
+ off()
38
+ } finally {
39
+ deactivate()
40
+ }
41
+ }
42
+ },
43
+ }
package/src/index.ts ADDED
@@ -0,0 +1,12 @@
1
+ export { KeyboardController } from './controller'
2
+ export { FocusedInputController } from './focused-input'
3
+ export type * from './focus-types'
4
+ export { KeyboardProvider } from './Provider.tsrx'
5
+ export type * from './types'
6
+ export type * from './props'
7
+
8
+ export { KeyboardScrollController } from './scroll-controller'
9
+ export { KeyboardAwareScrollView } from './KeyboardAwareScrollView'
10
+ export type * from './scroll-types'
11
+
12
+ export { KeyboardStickyView } from './KeyboardStickyView'
package/src/motion.ts ADDED
@@ -0,0 +1,82 @@
1
+ import { keyboardBottomOverlap } from '@octane-xplat/platform/keyboard-geometry'
2
+ import type { KeyboardRect } from '@octane-xplat/platform/keyboard-geometry'
3
+ import type { KeyboardWindowState } from '@octane-xplat/platform/keyboard-state'
4
+
5
+ /** Native adapters supply measurements; this layer never interpolates geometry. */
6
+ export function createKeyboardMotion(
7
+ publish: (state: KeyboardWindowState) => void,
8
+ capabilities: { interactive: boolean; floating: boolean },
9
+ ) {
10
+ let state: KeyboardWindowState | undefined
11
+ let transitionId = 0
12
+ let moving = false
13
+ let durationMs: number | null = null
14
+ const emit = (phase: NonNullable<KeyboardWindowState['motion']>['phase']) => {
15
+ if (!state) {
16
+ return
17
+ }
18
+ state = {
19
+ ...state,
20
+ motion: {
21
+ phase,
22
+ transitionId,
23
+ timestampMs: Date.now(),
24
+ durationMs,
25
+ nativeFrames: true,
26
+ ...capabilities,
27
+ },
28
+ }
29
+ publish(state)
30
+ }
31
+ return {
32
+ begin(target: KeyboardRect | null, duration: number | null = null) {
33
+ if (moving) {
34
+ emit('cancel')
35
+ }
36
+ transitionId++
37
+ moving = true
38
+ durationMs = duration !== null && Number.isFinite(duration) ? Math.max(0, duration) : null
39
+ if (state) {
40
+ state = {
41
+ ...state,
42
+ targetFrame: target,
43
+ inset: keyboardBottomOverlap(state.windowRect, target),
44
+ }
45
+ }
46
+ emit('start')
47
+ },
48
+ measure(
49
+ windowRect: KeyboardRect,
50
+ currentFrame: KeyboardRect,
51
+ targetFrame?: KeyboardRect | null,
52
+ bindingFrame?: KeyboardRect,
53
+ ) {
54
+ state = {
55
+ windowRect,
56
+ currentFrame,
57
+ bindingFrame,
58
+ targetFrame: targetFrame === undefined ? (state?.targetFrame ?? currentFrame) : targetFrame,
59
+ inset: keyboardBottomOverlap(
60
+ windowRect,
61
+ targetFrame === undefined ? (state?.targetFrame ?? currentFrame) : targetFrame,
62
+ ),
63
+ source: 'native-frames',
64
+ }
65
+ emit(moving ? 'move' : 'idle')
66
+ },
67
+ end(confirmed = false) {
68
+ if (!moving && !confirmed) {
69
+ return
70
+ }
71
+ moving = false
72
+ emit('end')
73
+ },
74
+ cancel() {
75
+ if (!moving) {
76
+ return
77
+ }
78
+ moving = false
79
+ emit('cancel')
80
+ },
81
+ }
82
+ }
@@ -0,0 +1,28 @@
1
+ /** @internal Release every owned native resource, including failed attachment. */
2
+ export function keyboardNativeScope() {
3
+ let active = true
4
+ const releases: (() => void)[] = []
5
+ return {
6
+ defer(release: () => void) {
7
+ releases.push(release)
8
+ },
9
+ release() {
10
+ if (!active) {
11
+ return
12
+ }
13
+ active = false
14
+ const errors: unknown[] = []
15
+ for (const release of releases.reverse()) {
16
+ try {
17
+ release()
18
+ } catch (error) {
19
+ errors.push(error)
20
+ }
21
+ }
22
+ releases.length = 0
23
+ if (errors.length) {
24
+ throw new AggregateError(errors, 'Keyboard native cleanup failed')
25
+ }
26
+ },
27
+ }
28
+ }
@@ -0,0 +1,32 @@
1
+ /** UIKit presentation measurements describe the displayed frame. A property
2
+ * commit made in that display callback appears on the next refresh. Estimate
3
+ * only that bounded interval; current geometry stays a separate measurement.
4
+ * @internal */
5
+ export function nextKeyboardPosition(options: {
6
+ current: number
7
+ previous: number
8
+ elapsedMs: number
9
+ nextMs: number
10
+ target: number
11
+ top: number
12
+ bottom: number
13
+ }): number {
14
+ const { current, previous, elapsedMs, nextMs, target, top, bottom } = options
15
+ if (
16
+ ![current, previous, elapsedMs, nextMs, target, top, bottom].every(Number.isFinite) ||
17
+ elapsedMs <= 0 ||
18
+ elapsedMs > 50 ||
19
+ nextMs <= 0 ||
20
+ nextMs > 34
21
+ ) {
22
+ return current
23
+ }
24
+ const velocity = (current - previous) / elapsedMs
25
+ let estimate = current + velocity * nextMs
26
+ // A reversal may continue in the old direction before turning. Preserve
27
+ // that measured direction, but do not overshoot a target being approached.
28
+ if ((target - current) * velocity > 0) {
29
+ estimate = velocity > 0 ? Math.min(target, estimate) : Math.max(target, estimate)
30
+ }
31
+ return Math.max(top, Math.min(bottom, estimate))
32
+ }
package/src/props.ts ADDED
@@ -0,0 +1,37 @@
1
+ import type { KeyboardAvoidingProps, ViewProps } from '@octane-xplat/ui'
2
+ /** Shared tracking for existing keyboard consumers. Native observers start with
3
+ * the first window reader and stop with the last. Web/desktop are neutral.
4
+ * @example
5
+ * ```tsx
6
+ * import { KeyboardProvider } from '@octane-xplat/keyboard'
7
+ * import { KeyboardAvoiding, TextInput } from '@octane-xplat/ui'
8
+ * <KeyboardProvider><KeyboardAvoiding><TextInput /></KeyboardAvoiding></KeyboardProvider>
9
+ * ```
10
+ */
11
+ export interface KeyboardProviderProps {
12
+ children?: unknown
13
+ /** Activate native tracking; false retains children and fallback behavior. @default true */
14
+ enabled?: boolean
15
+ }
16
+
17
+ /** A vertical form that exposes the focused field/caret while the keyboard moves. */
18
+ export interface KeyboardAwareScrollViewProps extends Omit<KeyboardAvoidingProps, 'ref'> {
19
+ /** Minimum visible gap; does not create scroll range. @default 8 */
20
+ clearance?: number
21
+ /** Additional bottom scroll range while the keyboard is open. @default 0 */
22
+ extraScrollSpace?: number
23
+ manualScroll?: 'pause' | 'follow'
24
+ keyboardClose?: 'preserve' | 'restore'
25
+ /** Mounted visibility binding; null on cleanup. */
26
+ ref?: (binding: import('./scroll-types').KeyboardScrollBinding | null) => void
27
+ }
28
+
29
+ /** Translate a control independently of surrounding layout as the native keyboard moves. */
30
+ export interface KeyboardStickyViewProps extends ViewProps {
31
+ /** Disabled keeps its ordinary layout and releases native movement. @default true */
32
+ enabled?: boolean
33
+ /** Additional upward offset while open, in dips. @default 0 */
34
+ openOffset?: number
35
+ /** Upward offset while closed, in dips. @default 0 */
36
+ closedOffset?: number
37
+ }
@@ -0,0 +1,10 @@
1
+ import type { KeyboardWindowState } from '@octane-xplat/platform/keyboard-state'
2
+
3
+ /** @internal Platform adapter; shared policy never reads SDK or DOM objects. */
4
+ export interface KeyboardRuntime {
5
+ activate(): () => void
6
+ subscribe(host: object, listener: (state: KeyboardWindowState) => void): () => void
7
+ getSnapshot(host: object): KeyboardWindowState
8
+ property(host: object): { read(): number; write(value: number): void } | null
9
+ overlap(host: object, state: KeyboardWindowState, translationY: number): number
10
+ }
@@ -0,0 +1,62 @@
1
+ import { keyboardWindows } from '@octane-xplat/platform/keyboard-state'
2
+ import { installKeyboardViewportAdapter } from '@octane-xplat/platform/keyboard-viewport'
3
+ import {
4
+ keyboardHostDisplacement,
5
+ keyboardSnapshotForHost,
6
+ observeKeyboardDestination,
7
+ subscribeKeyboardState,
8
+ } from '@octane-xplat/ui/nativescript/keyboard'
9
+
10
+ import { observeKeyboardMotion } from './source'
11
+ import type { KeyboardRuntime } from './runtime-types'
12
+ import { leaseKeyboardViewport } from './viewport'
13
+ import { focusRuntime } from './focus-runtime'
14
+
15
+ export const keyboardRuntime: KeyboardRuntime = {
16
+ activate() {
17
+ const offFocus = focusRuntime.activate()
18
+ let offViewport = () => {}
19
+ let offSource: () => void
20
+ try {
21
+ offViewport = installKeyboardViewportAdapter(leaseKeyboardViewport)
22
+ offSource = keyboardWindows.install(observeKeyboardMotion ?? observeKeyboardDestination)
23
+ } catch (error) {
24
+ try {
25
+ offViewport()
26
+ } finally {
27
+ offFocus()
28
+ }
29
+
30
+ throw error
31
+ }
32
+ let active = true
33
+ return () => {
34
+ if (!active) {
35
+ return
36
+ }
37
+ active = false
38
+ try {
39
+ offSource()
40
+ } finally {
41
+ try {
42
+ offViewport()
43
+ } finally {
44
+ offFocus()
45
+ }
46
+ }
47
+ }
48
+ },
49
+ subscribe: (host, listener) => subscribeKeyboardState(listener, host, false),
50
+ getSnapshot: keyboardSnapshotForHost,
51
+ property(host: any) {
52
+ return host?.nativeViewProtected
53
+ ? {
54
+ read: () => Number(host.translateY) || 0,
55
+ write: (value) => {
56
+ host.translateY = value
57
+ },
58
+ }
59
+ : null
60
+ },
61
+ overlap: keyboardHostDisplacement,
62
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,16 @@
1
+ import { unknownKeyboardState } from '@octane-xplat/platform/keyboard-state'
2
+ import type { KeyboardRuntime } from './runtime-types'
3
+
4
+ const state = unknownKeyboardState()
5
+
6
+ /** Web and desktop retain neutral software-keyboard behavior. */
7
+ export const keyboardRuntime: KeyboardRuntime = {
8
+ activate: () => () => {},
9
+ subscribe(_host, listener) {
10
+ listener(state)
11
+ return () => {}
12
+ },
13
+ getSnapshot: () => state,
14
+ property: () => null,
15
+ overlap: () => 0,
16
+ }
@@ -0,0 +1,136 @@
1
+ import { keyboardNativeScope } from './native-scope'
2
+ import { KeyboardController } from './controller'
3
+ import { FocusedInputController } from './focused-input'
4
+ import { focusScrollPlan } from './scroll-geometry'
5
+ import { scrollRuntime } from './scroll-runtime'
6
+ import type { KeyboardScrollOptions, KeyboardScrollService } from './scroll-types'
7
+
8
+ const checked = (options: KeyboardScrollOptions) => {
9
+ if (!Number.isFinite(options.clearance ?? 8) || (options.clearance ?? 8) < 0) {
10
+ throw new RangeError('Keyboard scroll clearance must be finite and nonnegative')
11
+ }
12
+ return { ...options }
13
+ }
14
+
15
+ /** Focus visibility without per-frame component rendering. */
16
+ export const KeyboardScrollController: KeyboardScrollService = {
17
+ bind(host, initial = {}) {
18
+ let options = checked(initial),
19
+ active = true,
20
+ paused = false,
21
+ applying = false
22
+ let focus = FocusedInputController.getSnapshot(host)
23
+ let keyboard = KeyboardController.getSnapshot(host)
24
+ let open = false
25
+ const restoration = new Map<object, { before: number; expected: number }>()
26
+ const scope = keyboardNativeScope()
27
+ const apply = () => {
28
+ if (!active || applying) {
29
+ return
30
+ }
31
+ applying = true
32
+ try {
33
+ const root = scrollRuntime.owner(host)
34
+ const frame = keyboard.currentFrame ?? keyboard.targetFrame
35
+ const bottom = keyboard.windowRect && keyboard.windowRect.y + keyboard.windowRect.height
36
+ const nowOpen =
37
+ keyboard.inset > 0 || !!(frame && bottom && frame.height > 0 && frame.y < bottom)
38
+
39
+ if (open && !nowOpen) {
40
+ if (options.keyboardClose === 'restore') {
41
+ for (const [owner, saved] of restoration) {
42
+ const current = scrollRuntime.read(owner)
43
+ if (current !== null && Math.abs(current - saved.expected) < 1) {
44
+ scrollRuntime.write(owner, saved.before)
45
+ }
46
+ }
47
+ }
48
+
49
+ restoration.clear()
50
+ open = false
51
+ return
52
+ }
53
+
54
+ open = nowOpen
55
+ if (!root || paused) {
56
+ return
57
+ }
58
+
59
+ for (const operation of focusScrollPlan(focus, keyboard, root, options.clearance ?? 8)) {
60
+ if (nowOpen) {
61
+ const previous = restoration.get(operation.id)
62
+ restoration.set(operation.id, {
63
+ before: previous?.before ?? operation.from,
64
+ expected: operation.to,
65
+ })
66
+ }
67
+
68
+ scrollRuntime.write(operation.id, operation.to)
69
+ }
70
+ } finally {
71
+ applying = false
72
+ }
73
+ }
74
+ const release = () => {
75
+ if (!active) {
76
+ return
77
+ }
78
+ active = false
79
+ try {
80
+ scope.release()
81
+ } finally {
82
+ restoration.clear()
83
+ }
84
+ }
85
+ try {
86
+ scope.defer(
87
+ scrollRuntime.watchManual(host, () => {
88
+ paused = options.manualScroll !== 'follow'
89
+ restoration.clear()
90
+ }),
91
+ )
92
+
93
+ scope.defer(
94
+ KeyboardController.onMove(host, (state) => {
95
+ keyboard = state
96
+ apply()
97
+ }),
98
+ )
99
+
100
+ scope.defer(
101
+ FocusedInputController.subscribe(host, (state) => {
102
+ if (
103
+ state.input !== focus.input ||
104
+ state.selection?.start !== focus.selection?.start ||
105
+ state.selection?.end !== focus.selection?.end
106
+ ) {
107
+ paused = false
108
+ }
109
+ focus = state
110
+ apply()
111
+ }),
112
+ )
113
+ } catch (error) {
114
+ release()
115
+ throw error
116
+ }
117
+ return {
118
+ release,
119
+ update(next) {
120
+ options = checked(next)
121
+ if (active) {
122
+ apply()
123
+ }
124
+ },
125
+ recheck() {
126
+ if (!active) {
127
+ return
128
+ }
129
+ paused = false
130
+ FocusedInputController.recheck(host)
131
+ focus = FocusedInputController.getSnapshot(host)
132
+ apply()
133
+ },
134
+ }
135
+ },
136
+ }
@@ -0,0 +1,72 @@
1
+ import type { KeyboardRect } from '@octane-xplat/platform/keyboard-geometry'
2
+ import type { FocusedInputSnapshot } from './focus-types'
3
+ import type { KeyboardSnapshot } from './types'
4
+
5
+ export interface FocusScrollOperation {
6
+ id: object
7
+ from: number
8
+ to: number
9
+ }
10
+
11
+ /** Plan nearest-first minimal scrolling; SDK writes live in platform leaves. */
12
+ export function focusScrollPlan(
13
+ focus: FocusedInputSnapshot,
14
+ keyboard: KeyboardSnapshot,
15
+ root: object,
16
+ clearance: number,
17
+ ): FocusScrollOperation[] {
18
+ const end = focus.scrollOwners.findIndex((owner) => owner.id === root)
19
+ if (end < 0 || !focus.inputRect || !focus.windowRect) {
20
+ return []
21
+ }
22
+ const owners = focus.scrollOwners.slice(0, end + 1)
23
+ let target: KeyboardRect = { ...focus.inputRect }
24
+ let windowBottom = focus.windowRect.y + focus.windowRect.height
25
+ const frame = keyboard.currentFrame ?? keyboard.targetFrame
26
+ if (
27
+ frame &&
28
+ frame.y + frame.height >= windowBottom - 1 &&
29
+ frame.height > 0 &&
30
+ frame.x < target.x + target.width &&
31
+ frame.x + frame.width > target.x
32
+ ) {
33
+ windowBottom = Math.min(windowBottom, frame.y)
34
+ }
35
+ const available =
36
+ Math.min(
37
+ ...owners.map(
38
+ (owner) =>
39
+ Math.min(owner.rect.y + owner.rect.height, windowBottom) -
40
+ Math.max(owner.rect.y, focus.windowRect!.y),
41
+ ),
42
+ ) -
43
+ clearance * 2
44
+ if (target.height > available && focus.caretRect) {
45
+ target = { ...focus.caretRect }
46
+ }
47
+ const result: FocusScrollOperation[] = []
48
+ for (const owner of owners) {
49
+ const top = Math.max(owner.rect.y, focus.windowRect.y) + clearance
50
+ const bottom = Math.min(owner.rect.y + owner.rect.height, windowBottom) - clearance
51
+ if (bottom <= top) {
52
+ continue
53
+ }
54
+
55
+ const delta =
56
+ target.height > bottom - top
57
+ ? target.y - top
58
+ : target.y < top
59
+ ? target.y - top
60
+ : target.y + target.height > bottom
61
+ ? target.y + target.height - bottom
62
+ : 0
63
+
64
+ const to = Math.max(owner.minOffsetY, Math.min(owner.maxOffsetY, owner.offsetY + delta))
65
+ const moved = to - owner.offsetY
66
+ if (Math.abs(moved) > 0.5) {
67
+ result.push({ id: owner.id, from: owner.offsetY, to })
68
+ target = { ...target, y: target.y - moved }
69
+ }
70
+ }
71
+ return result
72
+ }
@@ -0,0 +1,6 @@
1
+ export interface FocusScrollRuntime {
2
+ owner(host: object): object | null
3
+ read(owner: object): number | null
4
+ write(owner: object, offset: number): void
5
+ watchManual(host: object, listener: () => void): () => void
6
+ }
@@ -0,0 +1,41 @@
1
+ import { NSNotificationCenter, NSOperationQueue } from '@octane-xplat/platform/macos'
2
+ import type { FocusScrollRuntime } from './scroll-runtime-types'
3
+ export const scrollRuntime: FocusScrollRuntime = {
4
+ owner: (host) => (host as any).native ?? host,
5
+ read(owner: any) {
6
+ const clip = owner.contentView,
7
+ content = owner.documentView
8
+ if (!clip || !content) {
9
+ return null
10
+ }
11
+ const flipped =
12
+ typeof content.isFlipped === 'function' ? content.isFlipped() : content.isFlipped
13
+ const max = Math.max(0, content.bounds.size.height - clip.bounds.size.height)
14
+ return flipped ? clip.bounds.origin.y : max - clip.bounds.origin.y
15
+ },
16
+ write(owner: any, offset) {
17
+ const clip = owner.contentView,
18
+ content = owner.documentView
19
+ if (!clip || !content) {
20
+ return
21
+ }
22
+ const flipped =
23
+ typeof content.isFlipped === 'function' ? content.isFlipped() : content.isFlipped
24
+ const max = Math.max(0, content.bounds.size.height - clip.bounds.size.height)
25
+ clip.scrollToPoint({
26
+ x: clip.bounds.origin.x,
27
+ y: flipped ? Math.max(0, Math.min(max, offset)) : max - Math.max(0, Math.min(max, offset)),
28
+ })
29
+ owner.reflectScrolledClipView(clip)
30
+ },
31
+ watchManual(host: any, listener) {
32
+ const center = NSNotificationCenter.defaultCenter
33
+ const token = center.addObserverForNameObjectQueueUsingBlock(
34
+ 'NSScrollViewWillStartLiveScrollNotification',
35
+ host.native ?? host,
36
+ NSOperationQueue.mainQueue,
37
+ listener,
38
+ )
39
+ return () => center.removeObserver(token)
40
+ },
41
+ }
@@ -0,0 +1,46 @@
1
+ import { Utils } from '@nativescript/core'
2
+ import type { FocusScrollRuntime } from './scroll-runtime-types'
3
+ const native = (host: any) => host.nativeViewProtected ?? host.ios ?? host.android
4
+ export const scrollRuntime: FocusScrollRuntime = {
5
+ owner: (host) => native((host as any).native ?? host) ?? null,
6
+ read: (owner: any) =>
7
+ owner.setContentOffsetAnimated || typeof owner.mason_scrollTo !== 'function'
8
+ ? (owner.contentOffset?.y ?? Utils.layout.toDeviceIndependentPixels(owner.getScrollY()))
9
+ : owner.mason_scrollTop,
10
+ write(owner: any, offset) {
11
+ if (owner.setContentOffsetAnimated) {
12
+ owner.setContentOffsetAnimated(
13
+ {
14
+ x: owner.contentOffset.x,
15
+ y: Math.max(
16
+ -(owner.adjustedContentInset?.top ?? 0),
17
+ Math.min(
18
+ offset,
19
+ Math.max(
20
+ -(owner.adjustedContentInset?.top ?? 0),
21
+ owner.contentSize.height -
22
+ owner.bounds.size.height +
23
+ (owner.adjustedContentInset?.bottom ?? 0),
24
+ ),
25
+ ),
26
+ ),
27
+ },
28
+ false,
29
+ )
30
+ } else if (typeof owner.mason_scrollTo === 'function') {
31
+ owner.mason_scrollTo(NaN, offset, false)
32
+ } else {
33
+ owner.scrollTo(owner.getScrollX(), Utils.layout.toDevicePixels(offset))
34
+ }
35
+ },
36
+ watchManual(host: any, listener) {
37
+ const view = host.native ?? host
38
+ const touch = (event: any) => {
39
+ if (event.action === 'move') {
40
+ listener()
41
+ }
42
+ }
43
+ view.on('touch', touch)
44
+ return () => view.off('touch', touch)
45
+ },
46
+ }
@@ -0,0 +1,7 @@
1
+ import type { FocusScrollRuntime } from './scroll-runtime-types'
2
+ export const scrollRuntime: FocusScrollRuntime = {
3
+ owner: () => null,
4
+ read: () => null,
5
+ write() {},
6
+ watchManual: () => () => {},
7
+ }
@@ -0,0 +1,25 @@
1
+ import type { FocusScrollRuntime } from './scroll-runtime-types'
2
+ export const scrollRuntime: FocusScrollRuntime = {
3
+ owner: (host) => (host as any).native ?? host,
4
+ read: (owner) => (owner as HTMLElement).scrollTop,
5
+ write(owner, offset) {
6
+ ;(owner as HTMLElement).scrollTop = offset
7
+ },
8
+ watchManual(host, listener) {
9
+ const view = ((host as any).native ?? host) as HTMLElement
10
+ const events = ['wheel', 'touchmove', 'pointermove']
11
+ const manual = (event: Event) => {
12
+ if (event.type !== 'pointermove' || (event as PointerEvent).buttons) {
13
+ listener()
14
+ }
15
+ }
16
+ for (const event of events) {
17
+ view.addEventListener(event, manual, { passive: true })
18
+ }
19
+ return () => {
20
+ for (const event of events) {
21
+ view.removeEventListener(event, manual)
22
+ }
23
+ }
24
+ },
25
+ }