@octane-xplat/keyboard 0.0.1 → 0.17.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.
- package/README.md +153 -1
- package/UPSTREAM.md +12 -0
- package/package.json +77 -9
- package/src/KeyboardAwareScrollView.mobile.tsrx +54 -0
- package/src/KeyboardAwareScrollView.tsrx +53 -0
- package/src/KeyboardStickyView.mobile.tsrx +66 -0
- package/src/KeyboardStickyView.tsrx +9 -0
- package/src/Provider.tsrx +11 -0
- package/src/binding-types.ts +16 -0
- package/src/controller.ts +243 -0
- package/src/focus-clock.macos.ts +13 -0
- package/src/focus-geometry.ts +34 -0
- package/src/focus-publish.ts +14 -0
- package/src/focus-runtime-types.ts +9 -0
- package/src/focus-runtime.macos.ts +62 -0
- package/src/focus-runtime.mobile.ts +57 -0
- package/src/focus-runtime.ts +14 -0
- package/src/focus-runtime.web.ts +139 -0
- package/src/focus-source.android.ts +173 -0
- package/src/focus-source.ios.ts +202 -0
- package/src/focus-source.macos.ts +113 -0
- package/src/focus-source.ts +12 -0
- package/src/focus-source.web.ts +157 -0
- package/src/focus-types.ts +19 -0
- package/src/focused-input.ts +43 -0
- package/src/index.ts +12 -0
- package/src/motion.ts +82 -0
- package/src/native-scope.ts +28 -0
- package/src/prediction.ts +32 -0
- package/src/props.ts +37 -0
- package/src/runtime-types.ts +10 -0
- package/src/runtime.mobile.ts +62 -0
- package/src/runtime.ts +16 -0
- package/src/scroll-controller.ts +136 -0
- package/src/scroll-geometry.ts +72 -0
- package/src/scroll-runtime-types.ts +6 -0
- package/src/scroll-runtime.macos.ts +41 -0
- package/src/scroll-runtime.mobile.ts +46 -0
- package/src/scroll-runtime.ts +7 -0
- package/src/scroll-runtime.web.ts +25 -0
- package/src/scroll-types.ts +24 -0
- package/src/source.android.ts +179 -0
- package/src/source.ios.ts +283 -0
- package/src/source.ts +5 -0
- package/src/tsconfig.json +1 -0
- package/src/types.ts +45 -0
- package/src/viewport-types.ts +7 -0
- package/src/viewport.android.ts +238 -0
- package/src/viewport.ios.ts +6 -0
- package/src/viewport.ts +6 -0
- package/types/focus-types.d.ts +17 -0
- package/types/index.d.ts +20 -0
- package/types/props.d.ts +35 -0
- package/types/scroll-types.d.ts +22 -0
- package/types/types.d.ts +40 -0
- package/index.js +0 -1
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Geometry policy for vertical focused-input scrolling. */
|
|
2
|
+
export interface KeyboardScrollOptions {
|
|
3
|
+
/** Minimum gap around the input/caret, in window layout units. @default 8 */
|
|
4
|
+
clearance?: number
|
|
5
|
+
/** Pause after a user scroll until focus/selection changes or recheck. @default 'pause' */
|
|
6
|
+
manualScroll?: 'pause' | 'follow'
|
|
7
|
+
/** Keep the resulting offset, or restore untouched offsets on close. @default 'preserve' */
|
|
8
|
+
keyboardClose?: 'preserve' | 'restore'
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface KeyboardScrollBinding {
|
|
12
|
+
/** Explicitly resume visibility enforcement after validation or manual scrolling. */
|
|
13
|
+
recheck(): void
|
|
14
|
+
/** Apply changed options without restarting window observation. */
|
|
15
|
+
update(options: KeyboardScrollOptions): void
|
|
16
|
+
/** Release observation; preserves the current scroll position. */
|
|
17
|
+
release(): void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface KeyboardScrollService {
|
|
21
|
+
/** Attach to a vertical scroll viewport. Only scroll ancestors through this
|
|
22
|
+
* viewport are eligible; unrelated fields in the same window are ignored. */
|
|
23
|
+
bind(host: object, options?: KeyboardScrollOptions): KeyboardScrollBinding
|
|
24
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { Utils } from '@nativescript/core'
|
|
2
|
+
import { android } from '@octane-xplat/platform/android'
|
|
3
|
+
import type { KeyboardWindowSource } from '@octane-xplat/platform/keyboard-registry'
|
|
4
|
+
import type { KeyboardWindowState } from '@octane-xplat/platform/keyboard-state'
|
|
5
|
+
import { observeKeyboardDestination } from '@octane-xplat/ui/nativescript/keyboard'
|
|
6
|
+
import { createKeyboardMotion } from './motion'
|
|
7
|
+
import { keyboardNativeScope } from './native-scope'
|
|
8
|
+
|
|
9
|
+
let AnimationCallback: any
|
|
10
|
+
const callbackClass = (core: any) => {
|
|
11
|
+
if (!AnimationCallback) {
|
|
12
|
+
AnimationCallback = core.WindowInsetsAnimationCompat.Callback.extend({
|
|
13
|
+
onPrepare(this: any, animation: any) {
|
|
14
|
+
this.handlers?.prepare(animation)
|
|
15
|
+
},
|
|
16
|
+
onStart(this: any, animation: any, bounds: any) {
|
|
17
|
+
this.handlers?.start(animation)
|
|
18
|
+
return bounds
|
|
19
|
+
},
|
|
20
|
+
onProgress(this: any, insets: any) {
|
|
21
|
+
this.handlers?.progress(insets)
|
|
22
|
+
return insets
|
|
23
|
+
},
|
|
24
|
+
onEnd(this: any, animation: any) {
|
|
25
|
+
this.handlers?.end(animation)
|
|
26
|
+
},
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
return AnimationCallback
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Callback lives only on our helper; decor/foreign callbacks remain untouched. */
|
|
33
|
+
export const observeKeyboardMotion: KeyboardWindowSource<KeyboardWindowState> = (key, publish) => {
|
|
34
|
+
const decor = key as any
|
|
35
|
+
const core = (globalThis as any).androidx?.core?.view
|
|
36
|
+
if (
|
|
37
|
+
!core?.WindowInsetsAnimationCompat ||
|
|
38
|
+
android.os.Build.VERSION.SDK_INT < 30 ||
|
|
39
|
+
!decor.addView
|
|
40
|
+
) {
|
|
41
|
+
return observeKeyboardDestination(key, publish)
|
|
42
|
+
}
|
|
43
|
+
const scope = keyboardNativeScope()
|
|
44
|
+
try {
|
|
45
|
+
const helper = new android.view.View(decor.getContext())
|
|
46
|
+
helper.setImportantForAccessibility(2)
|
|
47
|
+
helper.setFocusable(false)
|
|
48
|
+
helper.setClickable(false)
|
|
49
|
+
decor.addView(helper, new android.view.ViewGroup.LayoutParams(0, 0))
|
|
50
|
+
scope.defer(() => {
|
|
51
|
+
const remove = () => {
|
|
52
|
+
if (helper.getParent() === decor) {
|
|
53
|
+
decor.removeView(helper)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// A reader may release from a child's native detach callback. Android
|
|
57
|
+
// is still iterating decor children then; removing one corrupts that walk.
|
|
58
|
+
setTimeout(remove, 0)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
const type = core.WindowInsetsCompat.Type
|
|
62
|
+
const dip = Utils.layout.toDeviceIndependentPixels
|
|
63
|
+
let targetInset = 0
|
|
64
|
+
const motion = createKeyboardMotion((state) => publish({ ...state, inset: targetInset }), {
|
|
65
|
+
interactive: false,
|
|
66
|
+
floating: false,
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
let active = true
|
|
70
|
+
scope.defer(() => {
|
|
71
|
+
active = false
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
let animation: any
|
|
75
|
+
let target: any
|
|
76
|
+
const geometry = (insets: any) => {
|
|
77
|
+
const width = dip(decor.getWidth()),
|
|
78
|
+
height = dip(decor.getHeight())
|
|
79
|
+
const ime = dip(insets?.getInsets(type.ime()).bottom ?? 0)
|
|
80
|
+
return {
|
|
81
|
+
window: { x: 0, y: 0, width, height },
|
|
82
|
+
frame: { x: 0, y: height - ime, width, height: ime },
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const read = () => core.ViewCompat.getRootWindowInsets(decor)
|
|
87
|
+
const destination = () => {
|
|
88
|
+
const insets = read()
|
|
89
|
+
if (!insets) {
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
target = geometry(insets).frame
|
|
93
|
+
targetInset = dip(
|
|
94
|
+
Math.max(
|
|
95
|
+
0,
|
|
96
|
+
insets.getInsets(type.ime()).bottom - insets.getInsets(type.systemBars()).bottom,
|
|
97
|
+
),
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const measure = (insets: any) => {
|
|
102
|
+
if (!active || !insets) {
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
const value = geometry(insets)
|
|
106
|
+
if (value.window.width > 0 && value.window.height > 0) {
|
|
107
|
+
motion.measure(value.window, value.frame, target ?? value.frame)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const callback = new (callbackClass(core))(1)
|
|
112
|
+
callback.handlers = {
|
|
113
|
+
prepare(next: any) {
|
|
114
|
+
if (!active || !(next.getTypeMask() & type.ime())) {
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
// Replacing a running animation cancels it before the new start.
|
|
118
|
+
if (animation) {
|
|
119
|
+
motion.cancel()
|
|
120
|
+
}
|
|
121
|
+
animation = next
|
|
122
|
+
},
|
|
123
|
+
start(next: any) {
|
|
124
|
+
if (!active || next !== animation) {
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
destination()
|
|
128
|
+
motion.begin(target, Number(next.getDurationMillis()))
|
|
129
|
+
},
|
|
130
|
+
progress(insets: any) {
|
|
131
|
+
measure(insets)
|
|
132
|
+
},
|
|
133
|
+
end(next: any) {
|
|
134
|
+
if (!active || next !== animation) {
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
measure(read())
|
|
138
|
+
motion.end()
|
|
139
|
+
animation = undefined
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
scope.defer(() => {
|
|
144
|
+
callback.handlers = undefined
|
|
145
|
+
core.ViewCompat.setWindowInsetsAnimationCallback(helper, null)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
core.ViewCompat.setWindowInsetsAnimationCallback(helper, callback)
|
|
149
|
+
const layoutListener = new android.view.ViewTreeObserver.OnGlobalLayoutListener({
|
|
150
|
+
onGlobalLayout() {
|
|
151
|
+
if (!active || animation) {
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
destination()
|
|
155
|
+
measure(read())
|
|
156
|
+
},
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
scope.defer(() => {
|
|
160
|
+
const tree = decor.getViewTreeObserver()
|
|
161
|
+
if (tree.isAlive()) {
|
|
162
|
+
tree.removeOnGlobalLayoutListener(layoutListener)
|
|
163
|
+
}
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
decor.getViewTreeObserver().addOnGlobalLayoutListener(layoutListener)
|
|
167
|
+
destination()
|
|
168
|
+
measure(read())
|
|
169
|
+
return () => scope.release()
|
|
170
|
+
} catch (error) {
|
|
171
|
+
try {
|
|
172
|
+
scope.release()
|
|
173
|
+
} catch (cleanup) {
|
|
174
|
+
throw new AggregateError([error, cleanup], 'Keyboard native attachment failed')
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
throw error
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import {
|
|
2
|
+
NSObject,
|
|
3
|
+
CADisplayLink,
|
|
4
|
+
NSRunLoop,
|
|
5
|
+
UIView,
|
|
6
|
+
NSLayoutConstraint,
|
|
7
|
+
NSNotificationCenter,
|
|
8
|
+
NSOperationQueue,
|
|
9
|
+
} from '@octane-xplat/platform/ios'
|
|
10
|
+
|
|
11
|
+
import { isKeyboardRect } from '@octane-xplat/platform/keyboard-geometry'
|
|
12
|
+
import type { KeyboardRect } from '@octane-xplat/platform/keyboard-geometry'
|
|
13
|
+
import type { KeyboardWindowSource } from '@octane-xplat/platform/keyboard-registry'
|
|
14
|
+
import type { KeyboardWindowState } from '@octane-xplat/platform/keyboard-state'
|
|
15
|
+
import { observeKeyboardDestination } from '@octane-xplat/ui/nativescript/keyboard'
|
|
16
|
+
import { createKeyboardMotion } from './motion'
|
|
17
|
+
import { nextKeyboardPosition } from './prediction'
|
|
18
|
+
import { keyboardNativeScope } from './native-scope'
|
|
19
|
+
|
|
20
|
+
let DisplayTarget: any
|
|
21
|
+
const displayTarget = () => {
|
|
22
|
+
if (!DisplayTarget) {
|
|
23
|
+
const interop = (globalThis as any).interop
|
|
24
|
+
DisplayTarget = (NSObject as any).extend(
|
|
25
|
+
{
|
|
26
|
+
['tick:'](this: any, link: any) {
|
|
27
|
+
this.onTick?.(link)
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
{ exposedMethods: { 'tick:': { returns: interop.types.void, params: [CADisplayLink] } } },
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
return DisplayTarget.new()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const rect = (value: any): KeyboardRect => ({
|
|
37
|
+
x: value.origin.x,
|
|
38
|
+
y: value.origin.y,
|
|
39
|
+
width: value.size.width,
|
|
40
|
+
height: value.size.height,
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
/** Own every guide, constraint and display-link target; never mutate a host guide. */
|
|
44
|
+
export const observeKeyboardMotion: KeyboardWindowSource<KeyboardWindowState> = (key, publish) => {
|
|
45
|
+
const window = key as any
|
|
46
|
+
const root = window.rootViewController?.view
|
|
47
|
+
if (!root?.keyboardLayoutGuide?.respondsToSelector('setUsesBottomSafeArea:')) {
|
|
48
|
+
return observeKeyboardDestination(key, publish)
|
|
49
|
+
}
|
|
50
|
+
const scope = keyboardNativeScope()
|
|
51
|
+
try {
|
|
52
|
+
const container = UIView.new()
|
|
53
|
+
container.translatesAutoresizingMaskIntoConstraints = false
|
|
54
|
+
container.userInteractionEnabled = false
|
|
55
|
+
container.accessibilityElementsHidden = true
|
|
56
|
+
root.addSubview(container)
|
|
57
|
+
scope.defer(() => container.removeFromSuperview())
|
|
58
|
+
const guide = container.keyboardLayoutGuide
|
|
59
|
+
guide.usesBottomSafeArea = false
|
|
60
|
+
const tracker = UIView.new()
|
|
61
|
+
tracker.translatesAutoresizingMaskIntoConstraints = false
|
|
62
|
+
tracker.userInteractionEnabled = false
|
|
63
|
+
container.addSubview(tracker)
|
|
64
|
+
const constraints = [
|
|
65
|
+
container.leadingAnchor.constraintEqualToAnchor(root.leadingAnchor),
|
|
66
|
+
container.trailingAnchor.constraintEqualToAnchor(root.trailingAnchor),
|
|
67
|
+
container.topAnchor.constraintEqualToAnchor(root.topAnchor),
|
|
68
|
+
container.bottomAnchor.constraintEqualToAnchor(root.bottomAnchor),
|
|
69
|
+
tracker.leadingAnchor.constraintEqualToAnchor(container.leadingAnchor),
|
|
70
|
+
tracker.trailingAnchor.constraintEqualToAnchor(container.trailingAnchor),
|
|
71
|
+
tracker.bottomAnchor.constraintEqualToAnchor(guide.topAnchor),
|
|
72
|
+
tracker.heightAnchor.constraintEqualToConstant(0),
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
scope.defer(() => NSLayoutConstraint.deactivateConstraints(constraints))
|
|
76
|
+
NSLayoutConstraint.activateConstraints(constraints)
|
|
77
|
+
root.layoutIfNeeded()
|
|
78
|
+
// A newly constrained view starts at the guide's model destination. It
|
|
79
|
+
// cannot measure a transition that began before it existed. Wait for a
|
|
80
|
+
// native completion and matching presentation before advertising frames.
|
|
81
|
+
let calibrated = false
|
|
82
|
+
let confirmedFrame: KeyboardRect | null = null
|
|
83
|
+
let bootstrapPhase: 'idle' | 'start' = 'idle'
|
|
84
|
+
let coarseKey = ''
|
|
85
|
+
const motion = createKeyboardMotion(
|
|
86
|
+
(state) => {
|
|
87
|
+
if (calibrated) {
|
|
88
|
+
publish(state)
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
const key = JSON.stringify([
|
|
92
|
+
state.windowRect,
|
|
93
|
+
state.targetFrame,
|
|
94
|
+
bootstrapPhase,
|
|
95
|
+
state.motion?.transitionId,
|
|
96
|
+
])
|
|
97
|
+
if (key === coarseKey) {
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
coarseKey = key
|
|
101
|
+
publish({
|
|
102
|
+
...state,
|
|
103
|
+
source: 'destination',
|
|
104
|
+
currentFrame: null,
|
|
105
|
+
bindingFrame: undefined,
|
|
106
|
+
motion: state.motion && {
|
|
107
|
+
...state.motion,
|
|
108
|
+
phase: bootstrapPhase,
|
|
109
|
+
nativeFrames: false,
|
|
110
|
+
interactive: false,
|
|
111
|
+
},
|
|
112
|
+
})
|
|
113
|
+
},
|
|
114
|
+
{ interactive: true, floating: false },
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
let active = true
|
|
118
|
+
scope.defer(() => {
|
|
119
|
+
active = false
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
let moving = false
|
|
123
|
+
let settled = 0
|
|
124
|
+
let lastY = NaN
|
|
125
|
+
let lastTimestamp = NaN
|
|
126
|
+
let lastWidth = NaN
|
|
127
|
+
let lastHeight = NaN
|
|
128
|
+
let targetFrame: KeyboardRect | null = null
|
|
129
|
+
const read = (link?: any) => {
|
|
130
|
+
if (!active || container.window !== window) {
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
const bounds = rect(window.bounds)
|
|
134
|
+
const current = tracker.layer.presentationLayer()?.frame ?? tracker.frame
|
|
135
|
+
const local = container.convertRectToView(current, window)
|
|
136
|
+
const y = local.origin.y
|
|
137
|
+
const frame = {
|
|
138
|
+
x: bounds.x,
|
|
139
|
+
y,
|
|
140
|
+
width: bounds.width,
|
|
141
|
+
height: Math.max(0, bounds.y + bounds.height - y),
|
|
142
|
+
}
|
|
143
|
+
if (!isKeyboardRect(bounds) || !isKeyboardRect(frame)) {
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
const destination = rect(container.convertRectToView(guide.layoutFrame, window))
|
|
147
|
+
if (!targetFrame) {
|
|
148
|
+
targetFrame = destination
|
|
149
|
+
}
|
|
150
|
+
const justCalibrated =
|
|
151
|
+
!calibrated &&
|
|
152
|
+
confirmedFrame !== null &&
|
|
153
|
+
Math.abs(y - confirmedFrame.y) < 0.5 &&
|
|
154
|
+
Math.abs(destination.y - confirmedFrame.y) < 0.5
|
|
155
|
+
if (justCalibrated) {
|
|
156
|
+
calibrated = true
|
|
157
|
+
}
|
|
158
|
+
if (!moving && Number.isFinite(lastY) && Math.abs(lastY - y) > 0.1) {
|
|
159
|
+
motion.begin(targetFrame)
|
|
160
|
+
moving = true
|
|
161
|
+
}
|
|
162
|
+
// Stable reads do not wake consumers at display refresh rate.
|
|
163
|
+
if (
|
|
164
|
+
justCalibrated ||
|
|
165
|
+
moving ||
|
|
166
|
+
!Number.isFinite(lastY) ||
|
|
167
|
+
Math.abs(lastY - y) > 0.01 ||
|
|
168
|
+
lastWidth !== bounds.width ||
|
|
169
|
+
lastHeight !== bounds.height
|
|
170
|
+
) {
|
|
171
|
+
const nextY =
|
|
172
|
+
moving && link
|
|
173
|
+
? nextKeyboardPosition({
|
|
174
|
+
current: y,
|
|
175
|
+
previous: lastY,
|
|
176
|
+
elapsedMs: (link.timestamp - lastTimestamp) * 1000,
|
|
177
|
+
nextMs: (link.targetTimestamp - link.timestamp) * 1000,
|
|
178
|
+
target: targetFrame.y,
|
|
179
|
+
top: bounds.y,
|
|
180
|
+
bottom: bounds.y + bounds.height,
|
|
181
|
+
})
|
|
182
|
+
: y
|
|
183
|
+
|
|
184
|
+
motion.measure(bounds, frame, targetFrame, {
|
|
185
|
+
...frame,
|
|
186
|
+
y: nextY,
|
|
187
|
+
height: Math.max(0, bounds.y + bounds.height - nextY),
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
if (justCalibrated) {
|
|
191
|
+
motion.end(true)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (moving) {
|
|
195
|
+
settled = Math.abs(y - destination.y) < 0.5 && Math.abs(y - lastY) < 0.1 ? settled + 1 : 0
|
|
196
|
+
if (settled >= 2) {
|
|
197
|
+
moving = false
|
|
198
|
+
motion.end()
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
lastY = y
|
|
202
|
+
lastTimestamp = link?.timestamp ?? NaN
|
|
203
|
+
lastWidth = bounds.width
|
|
204
|
+
lastHeight = bounds.height
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
read()
|
|
208
|
+
const target = displayTarget()
|
|
209
|
+
target.onTick = read
|
|
210
|
+
const link = CADisplayLink.displayLinkWithTargetSelector(target, 'tick:')
|
|
211
|
+
scope.defer(() => {
|
|
212
|
+
target.onTick = undefined
|
|
213
|
+
link.invalidate()
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
link.addToRunLoopForMode(NSRunLoop.mainRunLoop, 'kCFRunLoopCommonModes')
|
|
217
|
+
const center = NSNotificationCenter.defaultCenter
|
|
218
|
+
const token = center.addObserverForNameObjectQueueUsingBlock(
|
|
219
|
+
UIKeyboardWillChangeFrameNotification,
|
|
220
|
+
null,
|
|
221
|
+
NSOperationQueue.mainQueue,
|
|
222
|
+
(note: any) => {
|
|
223
|
+
if (!active || (note.object?.coordinateSpace && note.object !== window.screen)) {
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
const raw = note.userInfo.objectForKey(UIKeyboardFrameEndUserInfoKey)
|
|
227
|
+
const value = typeof raw?.CGRectValue === 'function' ? raw.CGRectValue() : raw?.CGRectValue
|
|
228
|
+
if (!value) {
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
const local = window.screen?.coordinateSpace
|
|
232
|
+
? window.convertRectFromCoordinateSpace(value, window.screen.coordinateSpace)
|
|
233
|
+
: window.convertRectFromWindow(value, null)
|
|
234
|
+
targetFrame = rect(local)
|
|
235
|
+
bootstrapPhase = 'start'
|
|
236
|
+
confirmedFrame = null
|
|
237
|
+
const duration =
|
|
238
|
+
Number(note.userInfo.objectForKey(UIKeyboardAnimationDurationUserInfoKey)) * 1000
|
|
239
|
+
motion.begin(targetFrame, Number.isFinite(duration) ? duration : null)
|
|
240
|
+
moving = true
|
|
241
|
+
settled = 0
|
|
242
|
+
},
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
scope.defer(() => center.removeObserver(token))
|
|
246
|
+
const completion = center.addObserverForNameObjectQueueUsingBlock(
|
|
247
|
+
UIKeyboardDidChangeFrameNotification,
|
|
248
|
+
null,
|
|
249
|
+
NSOperationQueue.mainQueue,
|
|
250
|
+
(note: any) => {
|
|
251
|
+
if (
|
|
252
|
+
!active ||
|
|
253
|
+
calibrated ||
|
|
254
|
+
(note.object?.coordinateSpace && note.object !== window.screen)
|
|
255
|
+
) {
|
|
256
|
+
return
|
|
257
|
+
}
|
|
258
|
+
const raw = note.userInfo.objectForKey(UIKeyboardFrameEndUserInfoKey)
|
|
259
|
+
const value = typeof raw?.CGRectValue === 'function' ? raw.CGRectValue() : raw?.CGRectValue
|
|
260
|
+
if (!value) {
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
confirmedFrame = rect(
|
|
264
|
+
window.screen?.coordinateSpace
|
|
265
|
+
? window.convertRectFromCoordinateSpace(value, window.screen.coordinateSpace)
|
|
266
|
+
: window.convertRectFromWindow(value, null),
|
|
267
|
+
)
|
|
268
|
+
targetFrame = confirmedFrame
|
|
269
|
+
},
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
scope.defer(() => center.removeObserver(completion))
|
|
273
|
+
return () => scope.release()
|
|
274
|
+
} catch (error) {
|
|
275
|
+
try {
|
|
276
|
+
scope.release()
|
|
277
|
+
} catch (cleanup) {
|
|
278
|
+
throw new AggregateError([error, cleanup], 'Keyboard native attachment failed')
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
throw error
|
|
282
|
+
}
|
|
283
|
+
}
|
package/src/source.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { KeyboardWindowSource } from '@octane-xplat/platform/keyboard-registry'
|
|
2
|
+
import type { KeyboardWindowState } from '@octane-xplat/platform/keyboard-state'
|
|
3
|
+
|
|
4
|
+
/** Unsupported native contexts retain destination observation. */
|
|
5
|
+
export const observeKeyboardMotion: KeyboardWindowSource<KeyboardWindowState> | null = null
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"extends":"../../../tsconfig.base.json"}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { KeyboardRect } from '@octane-xplat/platform/keyboard-geometry'
|
|
2
|
+
|
|
3
|
+
export type { KeyboardRect } from '@octane-xplat/platform/keyboard-geometry'
|
|
4
|
+
|
|
5
|
+
/** Lifecycle of one keyboard transition. An interrupted transition cancels
|
|
6
|
+
* before the replacement starts; current geometry never jumps to its target. */
|
|
7
|
+
export type KeyboardPhase = 'idle' | 'start' | 'move' | 'end' | 'cancel'
|
|
8
|
+
|
|
9
|
+
/** Capabilities belong to the actual host window and active adapter. */
|
|
10
|
+
export interface KeyboardCapabilities {
|
|
11
|
+
readonly geometry: boolean
|
|
12
|
+
readonly nativeFrames: boolean
|
|
13
|
+
readonly interactive: boolean
|
|
14
|
+
readonly floating: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Geometry is in device-independent coordinates in the owning window.
|
|
18
|
+
* Null frames are unavailable; source distinguishes unknown from measured hidden.
|
|
19
|
+
* A destination frame is never presented as measured intermediate movement. */
|
|
20
|
+
export interface KeyboardSnapshot {
|
|
21
|
+
readonly inset: number
|
|
22
|
+
readonly windowRect: KeyboardRect | null
|
|
23
|
+
readonly currentFrame: KeyboardRect | null
|
|
24
|
+
readonly targetFrame: KeyboardRect | null
|
|
25
|
+
readonly source: 'unknown' | 'destination' | 'native-frames'
|
|
26
|
+
readonly capabilities: KeyboardCapabilities
|
|
27
|
+
readonly phase: KeyboardPhase
|
|
28
|
+
readonly transitionId: number
|
|
29
|
+
/** Unix milliseconds at delivery. */
|
|
30
|
+
readonly timestampMs: number
|
|
31
|
+
/** Native destination transition duration, or null when unknown. */
|
|
32
|
+
readonly durationMs: number | null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface SoftwareKeyboardController {
|
|
36
|
+
/** Activate shared native tracking until release. Observation starts only
|
|
37
|
+
* when a window has readers. Repeated activations share ownership. */
|
|
38
|
+
activate(): () => void
|
|
39
|
+
/** Read available cached state without activating observation. */
|
|
40
|
+
getSnapshot(host: object): KeyboardSnapshot
|
|
41
|
+
/** Observe destination/lifecycle changes, excluding per-frame movement. */
|
|
42
|
+
subscribe(host: object, listener: (snapshot: KeyboardSnapshot) => void): () => void
|
|
43
|
+
/** Observe measured movement directly; callbacks must not render per frame. */
|
|
44
|
+
onMove(host: object, listener: (snapshot: KeyboardSnapshot) => void): () => void
|
|
45
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** @internal A consumer-owned native viewport policy, separate from observation. */
|
|
2
|
+
export interface KeyboardViewportLease {
|
|
3
|
+
readonly supported: boolean
|
|
4
|
+
/** Follow replacement native views belonging to the same framework host. */
|
|
5
|
+
update(): void
|
|
6
|
+
release(): void
|
|
7
|
+
}
|