aix 0.0.14 → 0.1.0-alpha.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/Aix.podspec +32 -0
- package/LICENSE +2 -2
- package/README.md +165 -33
- package/android/CMakeLists.txt +32 -0
- package/android/build.gradle +148 -0
- package/android/fix-prefab.gradle +51 -0
- package/android/gradle.properties +5 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/cpp/cpp-adapter.cpp +6 -0
- package/android/src/main/java/com/aix/AixPackage.kt +29 -0
- package/android/src/main/java/com/aix/HybridAix.kt +27 -0
- package/ios/Bridge.h +8 -0
- package/ios/HybridAix.swift +1072 -0
- package/ios/HybridAixCellView.swift +174 -0
- package/ios/HybridAixComposer.swift +119 -0
- package/lib/commonjs/aix.js +25 -0
- package/lib/commonjs/aix.js.map +1 -0
- package/lib/commonjs/fade-in/createUsePool.js +50 -0
- package/lib/commonjs/fade-in/createUsePool.js.map +1 -0
- package/lib/commonjs/fade-in/createUseStaggered.js +82 -0
- package/lib/commonjs/fade-in/createUseStaggered.js.map +1 -0
- package/lib/commonjs/fade-in/index.js +78 -0
- package/lib/commonjs/fade-in/index.js.map +1 -0
- package/lib/commonjs/footer.js +28 -0
- package/lib/commonjs/footer.js.map +1 -0
- package/lib/commonjs/index.js +48 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/package.json +1 -0
- package/lib/commonjs/views/aix.nitro.js +6 -0
- package/lib/commonjs/views/aix.nitro.js.map +1 -0
- package/lib/module/aix.js +20 -0
- package/lib/module/aix.js.map +1 -0
- package/lib/module/fade-in/createUsePool.js +46 -0
- package/lib/module/fade-in/createUsePool.js.map +1 -0
- package/lib/module/fade-in/createUseStaggered.js +79 -0
- package/lib/module/fade-in/createUseStaggered.js.map +1 -0
- package/lib/module/fade-in/index.js +74 -0
- package/lib/module/fade-in/index.js.map +1 -0
- package/lib/module/footer.js +23 -0
- package/lib/module/footer.js.map +1 -0
- package/lib/module/index.js +13 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/views/aix.nitro.js +4 -0
- package/lib/module/views/aix.nitro.js.map +1 -0
- package/lib/typescript/src/aix.d.ts +14 -0
- package/lib/typescript/src/aix.d.ts.map +1 -0
- package/lib/typescript/src/fade-in/createUsePool.d.ts +5 -0
- package/lib/typescript/src/fade-in/createUsePool.d.ts.map +1 -0
- package/lib/typescript/src/fade-in/createUseStaggered.d.ts +2 -0
- package/lib/typescript/src/fade-in/createUseStaggered.d.ts.map +1 -0
- package/lib/typescript/src/fade-in/index.d.ts +5 -0
- package/lib/typescript/src/fade-in/index.d.ts.map +1 -0
- package/lib/typescript/src/footer.d.ts +5 -0
- package/lib/typescript/src/footer.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +10 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/views/aix.nitro.d.ts +101 -0
- package/lib/typescript/src/views/aix.nitro.d.ts.map +1 -0
- package/nitro.json +26 -0
- package/nitrogen/generated/.gitattributes +1 -0
- package/nitrogen/generated/android/Aix+autolinking.cmake +91 -0
- package/nitrogen/generated/android/Aix+autolinking.gradle +27 -0
- package/nitrogen/generated/android/AixOnLoad.cpp +70 -0
- package/nitrogen/generated/android/AixOnLoad.hpp +25 -0
- package/nitrogen/generated/android/c++/JAixAdditionalContentInsets.hpp +61 -0
- package/nitrogen/generated/android/c++/JAixAdditionalContentInsetsProp.hpp +63 -0
- package/nitrogen/generated/android/c++/JAixScrollIndicatorInsetValue.hpp +61 -0
- package/nitrogen/generated/android/c++/JAixScrollIndicatorInsets.hpp +63 -0
- package/nitrogen/generated/android/c++/JAixScrollOnFooterSizeUpdate.hpp +65 -0
- package/nitrogen/generated/android/c++/JHybridAixCellViewSpec.cpp +65 -0
- package/nitrogen/generated/android/c++/JHybridAixCellViewSpec.hpp +68 -0
- package/nitrogen/generated/android/c++/JHybridAixComposerSpec.cpp +48 -0
- package/nitrogen/generated/android/c++/JHybridAixComposerSpec.hpp +65 -0
- package/nitrogen/generated/android/c++/JHybridAixSpec.cpp +137 -0
- package/nitrogen/generated/android/c++/JHybridAixSpec.hpp +79 -0
- package/nitrogen/generated/android/c++/views/JHybridAixCellViewStateUpdater.cpp +60 -0
- package/nitrogen/generated/android/c++/views/JHybridAixCellViewStateUpdater.hpp +49 -0
- package/nitrogen/generated/android/c++/views/JHybridAixComposerStateUpdater.cpp +53 -0
- package/nitrogen/generated/android/c++/views/JHybridAixComposerStateUpdater.hpp +49 -0
- package/nitrogen/generated/android/c++/views/JHybridAixStateUpdater.cpp +80 -0
- package/nitrogen/generated/android/c++/views/JHybridAixStateUpdater.hpp +49 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/AixAdditionalContentInsets.kt +41 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/AixAdditionalContentInsetsProp.kt +41 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/AixOnLoad.kt +35 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/AixScrollIndicatorInsetValue.kt +41 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/AixScrollIndicatorInsets.kt +41 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/AixScrollOnFooterSizeUpdate.kt +44 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/HybridAixCellViewSpec.kt +65 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/HybridAixComposerSpec.kt +55 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/HybridAixSpec.kt +101 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/views/HybridAixCellViewManager.kt +50 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/views/HybridAixCellViewStateUpdater.kt +23 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/views/HybridAixComposerManager.kt +50 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/views/HybridAixComposerStateUpdater.kt +23 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/views/HybridAixManager.kt +50 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/views/HybridAixStateUpdater.kt +23 -0
- package/nitrogen/generated/ios/Aix+autolinking.rb +60 -0
- package/nitrogen/generated/ios/Aix-Swift-Cxx-Bridge.cpp +67 -0
- package/nitrogen/generated/ios/Aix-Swift-Cxx-Bridge.hpp +222 -0
- package/nitrogen/generated/ios/Aix-Swift-Cxx-Umbrella.hpp +70 -0
- package/nitrogen/generated/ios/AixAutolinking.mm +49 -0
- package/nitrogen/generated/ios/AixAutolinking.swift +55 -0
- package/nitrogen/generated/ios/c++/HybridAixCellViewSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridAixCellViewSpecSwift.hpp +80 -0
- package/nitrogen/generated/ios/c++/HybridAixComposerSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridAixComposerSpecSwift.hpp +69 -0
- package/nitrogen/generated/ios/c++/HybridAixSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridAixSpecSwift.hpp +142 -0
- package/nitrogen/generated/ios/c++/views/HybridAixCellViewComponent.mm +101 -0
- package/nitrogen/generated/ios/c++/views/HybridAixComponent.mm +126 -0
- package/nitrogen/generated/ios/c++/views/HybridAixComposerComponent.mm +92 -0
- package/nitrogen/generated/ios/swift/AixAdditionalContentInsets.swift +47 -0
- package/nitrogen/generated/ios/swift/AixAdditionalContentInsetsProp.swift +71 -0
- package/nitrogen/generated/ios/swift/AixScrollIndicatorInsetValue.swift +47 -0
- package/nitrogen/generated/ios/swift/AixScrollIndicatorInsets.swift +71 -0
- package/nitrogen/generated/ios/swift/AixScrollOnFooterSizeUpdate.swift +89 -0
- package/nitrogen/generated/ios/swift/HybridAixCellViewSpec.swift +57 -0
- package/nitrogen/generated/ios/swift/HybridAixCellViewSpec_cxx.swift +151 -0
- package/nitrogen/generated/ios/swift/HybridAixComposerSpec.swift +56 -0
- package/nitrogen/generated/ios/swift/HybridAixComposerSpec_cxx.swift +131 -0
- package/nitrogen/generated/ios/swift/HybridAixSpec.swift +63 -0
- package/nitrogen/generated/ios/swift/HybridAixSpec_cxx.swift +292 -0
- package/nitrogen/generated/shared/c++/AixAdditionalContentInsets.hpp +79 -0
- package/nitrogen/generated/shared/c++/AixAdditionalContentInsetsProp.hpp +81 -0
- package/nitrogen/generated/shared/c++/AixScrollIndicatorInsetValue.hpp +79 -0
- package/nitrogen/generated/shared/c++/AixScrollIndicatorInsets.hpp +81 -0
- package/nitrogen/generated/shared/c++/AixScrollOnFooterSizeUpdate.hpp +83 -0
- package/nitrogen/generated/shared/c++/HybridAixCellViewSpec.cpp +24 -0
- package/nitrogen/generated/shared/c++/HybridAixCellViewSpec.hpp +65 -0
- package/nitrogen/generated/shared/c++/HybridAixComposerSpec.cpp +21 -0
- package/nitrogen/generated/shared/c++/HybridAixComposerSpec.hpp +62 -0
- package/nitrogen/generated/shared/c++/HybridAixSpec.cpp +36 -0
- package/nitrogen/generated/shared/c++/HybridAixSpec.hpp +85 -0
- package/nitrogen/generated/shared/c++/views/HybridAixCellViewComponent.cpp +99 -0
- package/nitrogen/generated/shared/c++/views/HybridAixCellViewComponent.hpp +108 -0
- package/nitrogen/generated/shared/c++/views/HybridAixComponent.cpp +159 -0
- package/nitrogen/generated/shared/c++/views/HybridAixComponent.hpp +117 -0
- package/nitrogen/generated/shared/c++/views/HybridAixComposerComponent.cpp +75 -0
- package/nitrogen/generated/shared/c++/views/HybridAixComposerComponent.hpp +106 -0
- package/nitrogen/generated/shared/json/AixCellViewConfig.json +11 -0
- package/nitrogen/generated/shared/json/AixComposerConfig.json +9 -0
- package/nitrogen/generated/shared/json/AixConfig.json +16 -0
- package/package.json +115 -12
- package/src/aix.tsx +43 -0
- package/src/fade-in/createUsePool.ts +46 -0
- package/src/fade-in/createUseStaggered.ts +82 -0
- package/src/fade-in/index.tsx +97 -0
- package/src/footer.tsx +30 -0
- package/src/index.ts +20 -16
- package/src/views/aix.nitro.ts +148 -0
- package/docs/API.md +0 -193
- package/jest.config.js +0 -17
- package/lib/__tests__/deferredIterable.test.d.ts +0 -1
- package/lib/__tests__/deferredIterable.test.js +0 -111
- package/lib/__tests__/filter.test.d.ts +0 -1
- package/lib/__tests__/filter.test.js +0 -56
- package/lib/__tests__/flatMap.test.d.ts +0 -1
- package/lib/__tests__/flatMap.test.js +0 -80
- package/lib/__tests__/lookahead.test.d.ts +0 -1
- package/lib/__tests__/lookahead.test.js +0 -60
- package/lib/__tests__/map.test.d.ts +0 -1
- package/lib/__tests__/map.test.js +0 -56
- package/lib/__tests__/merge.test.d.ts +0 -1
- package/lib/__tests__/merge.test.js +0 -58
- package/lib/__tests__/reduce.test.d.ts +0 -1
- package/lib/__tests__/reduce.test.js +0 -55
- package/lib/__tests__/spanAll.test.d.ts +0 -1
- package/lib/__tests__/spanAll.test.js +0 -123
- package/lib/concat.d.ts +0 -5
- package/lib/concat.js +0 -127
- package/lib/deferred.d.ts +0 -10
- package/lib/deferred.js +0 -19
- package/lib/deferredIterable.d.ts +0 -23
- package/lib/deferredIterable.js +0 -112
- package/lib/filter.d.ts +0 -8
- package/lib/filter.js +0 -100
- package/lib/flatMap.d.ts +0 -2
- package/lib/flatMap.js +0 -120
- package/lib/fromEvent.d.ts +0 -6
- package/lib/fromEvent.js +0 -17
- package/lib/index.d.ts +0 -16
- package/lib/index.js +0 -34
- package/lib/insert.d.ts +0 -5
- package/lib/insert.js +0 -114
- package/lib/interval.d.ts +0 -5
- package/lib/interval.js +0 -68
- package/lib/iterableToArray.d.ts +0 -2
- package/lib/iterableToArray.js +0 -88
- package/lib/iteratorToIterable.d.ts +0 -7
- package/lib/iteratorToIterable.js +0 -71
- package/lib/lookahead.d.ts +0 -11
- package/lib/lookahead.js +0 -82
- package/lib/map.d.ts +0 -5
- package/lib/map.js +0 -99
- package/lib/merge.d.ts +0 -7
- package/lib/merge.js +0 -25
- package/lib/reduce.d.ts +0 -5
- package/lib/reduce.js +0 -93
- package/lib/restToIterable.d.ts +0 -5
- package/lib/restToIterable.js +0 -74
- package/lib/spanAll.d.ts +0 -2
- package/lib/spanAll.js +0 -34
- package/lib/tap.d.ts +0 -5
- package/lib/tap.js +0 -97
- package/lib/toCallbacks.d.ts +0 -12
- package/lib/toCallbacks.js +0 -98
- package/lib/zip.d.ts +0 -5
- package/lib/zip.js +0 -76
- package/src/__tests__/deferredIterable.test.ts +0 -22
- package/src/__tests__/filter.test.ts +0 -10
- package/src/__tests__/flatMap.test.ts +0 -12
- package/src/__tests__/lookahead.test.ts +0 -9
- package/src/__tests__/map.test.ts +0 -10
- package/src/__tests__/merge.test.ts +0 -9
- package/src/__tests__/reduce.test.ts +0 -10
- package/src/__tests__/spanAll.test.ts +0 -17
- package/src/concat.ts +0 -13
- package/src/deferred.ts +0 -15
- package/src/deferredIterable.ts +0 -111
- package/src/filter.ts +0 -16
- package/src/flatMap.ts +0 -9
- package/src/fromEvent.ts +0 -16
- package/src/insert.ts +0 -13
- package/src/interval.ts +0 -20
- package/src/iterableToArray.ts +0 -7
- package/src/iteratorToIterable.ts +0 -12
- package/src/lookahead.ts +0 -27
- package/src/map.ts +0 -11
- package/src/merge.ts +0 -16
- package/src/of.ts +0 -4
- package/src/reduce.ts +0 -12
- package/src/restToIterable.ts +0 -8
- package/src/spanAll.ts +0 -26
- package/src/tap.ts +0 -11
- package/src/toCallbacks.ts +0 -27
- package/src/zip.ts +0 -19
- package/tsconfig.json +0 -63
- package/yarn.lock +0 -3514
|
@@ -0,0 +1,1072 @@
|
|
|
1
|
+
//
|
|
2
|
+
// HybridAix.swift
|
|
3
|
+
// Pods
|
|
4
|
+
//
|
|
5
|
+
// Created by Fernando Rojo on 12/11/2025.
|
|
6
|
+
//
|
|
7
|
+
|
|
8
|
+
import Foundation
|
|
9
|
+
import UIKit
|
|
10
|
+
import ObjectiveC.runtime
|
|
11
|
+
|
|
12
|
+
private var aixContextKey: UInt8 = 0
|
|
13
|
+
|
|
14
|
+
/// Protocol that defines the Aix context interface for child views to communicate with
|
|
15
|
+
protocol AixContext: AnyObject {
|
|
16
|
+
/// The blank view (last cell) - used for calculating blank size
|
|
17
|
+
var blankView: HybridAixCellView? { get set }
|
|
18
|
+
|
|
19
|
+
/// The composer view
|
|
20
|
+
var composerView: HybridAixComposer? { get set }
|
|
21
|
+
|
|
22
|
+
/// Called when the blank view's size changes
|
|
23
|
+
func reportBlankViewSizeChange(size: CGSize, index: Int)
|
|
24
|
+
|
|
25
|
+
/// Register a cell with the context
|
|
26
|
+
func registerCell(_ cell: HybridAixCellView)
|
|
27
|
+
|
|
28
|
+
/// Unregister a cell from the context
|
|
29
|
+
func unregisterCell(_ cell: HybridAixCellView)
|
|
30
|
+
|
|
31
|
+
/// Register the composer view
|
|
32
|
+
func registerComposerView(_ composerView: HybridAixComposer)
|
|
33
|
+
<<<<<<< HEAD
|
|
34
|
+
|
|
35
|
+
/// Unregister the composer view
|
|
36
|
+
func unregisterComposerView(_ composerView: HybridAixComposer)
|
|
37
|
+
|
|
38
|
+
/// Called when the composer's height changes
|
|
39
|
+
func reportComposerHeightChange(height: CGFloat)
|
|
40
|
+
=======
|
|
41
|
+
|
|
42
|
+
/// Unregister the composer view
|
|
43
|
+
func unregisterComposerView(_ composerView: HybridAixComposer)
|
|
44
|
+
>>>>>>> e1f4b6081b44dde75cebf1c5e876c6c02fd5a7ef
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
extension UIView {
|
|
48
|
+
/// Get/set the Aix context associated with this view
|
|
49
|
+
/// Uses OBJC_ASSOCIATION_ASSIGN to avoid retain cycles (weak reference behavior)
|
|
50
|
+
var aixContext: AixContext? {
|
|
51
|
+
get { objc_getAssociatedObject(self, &aixContextKey) as? AixContext }
|
|
52
|
+
set { objc_setAssociatedObject(self, &aixContextKey, newValue, .OBJC_ASSOCIATION_ASSIGN) }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/// Walk up the view hierarchy to find the nearest AixContext
|
|
56
|
+
func useAixContext() -> AixContext? {
|
|
57
|
+
var node: UIView? = self
|
|
58
|
+
while let current = node {
|
|
59
|
+
if let ctx = current.aixContext {
|
|
60
|
+
return ctx
|
|
61
|
+
}
|
|
62
|
+
node = current.superview
|
|
63
|
+
}
|
|
64
|
+
return nil
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/// Recursively search subviews to find a UIScrollView by accessibilityIdentifier (nativeID)
|
|
68
|
+
func findScrollView(withIdentifier identifier: String) -> UIScrollView? {
|
|
69
|
+
if let scrollView = self as? UIScrollView, scrollView.accessibilityIdentifier == identifier {
|
|
70
|
+
return scrollView
|
|
71
|
+
}
|
|
72
|
+
for subview in subviews {
|
|
73
|
+
if let scrollView = subview.findScrollView(withIdentifier: identifier) {
|
|
74
|
+
return scrollView
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return nil
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/// Recursively search subviews to find the first UIScrollView
|
|
81
|
+
func findScrollView() -> UIScrollView? {
|
|
82
|
+
if let scrollView = self as? UIScrollView {
|
|
83
|
+
return scrollView
|
|
84
|
+
}
|
|
85
|
+
for subview in subviews {
|
|
86
|
+
if let scrollView = subview.findScrollView() {
|
|
87
|
+
return scrollView
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return nil
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// MARK: - HybridAix (Root Context)
|
|
95
|
+
|
|
96
|
+
class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
|
|
97
|
+
|
|
98
|
+
var penultimateCellIndex: Double?
|
|
99
|
+
|
|
100
|
+
var additionalContentInsets: AixAdditionalContentInsetsProp?
|
|
101
|
+
|
|
102
|
+
var additionalScrollIndicatorInsets: AixScrollIndicatorInsets? {
|
|
103
|
+
didSet {
|
|
104
|
+
guard cachedScrollView != nil else { return }
|
|
105
|
+
applyScrollIndicatorInsets()
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
var scrollOnComposerSizeUpdate: AixScrollOnFooterSizeUpdate?
|
|
110
|
+
|
|
111
|
+
var mainScrollViewID: String?
|
|
112
|
+
|
|
113
|
+
func scrollToEnd(animated: Bool?) {
|
|
114
|
+
// Dispatch to main thread since this may be called from RN background thread
|
|
115
|
+
DispatchQueue.main.async { [weak self] in
|
|
116
|
+
self?.scrollToEndInternal(animated: animated)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
func scrollToIndexWhenBlankSizeReady(index: Double, animated: Bool?, waitForKeyboardToEnd: Bool?) throws {
|
|
121
|
+
queuedScrollToEnd = QueuedScrollToEnd(index: Int(index), animated: animated ?? true, waitForKeyboardToEnd: waitForKeyboardToEnd ?? false)
|
|
122
|
+
|
|
123
|
+
DispatchQueue.main.async { [weak self] in
|
|
124
|
+
guard let self else { return }
|
|
125
|
+
|
|
126
|
+
// Clear any in-progress keyboard scroll interpolation since we're taking over scrolling
|
|
127
|
+
if let event = self.startEvent {
|
|
128
|
+
self.startEvent = KeyboardStartEvent(
|
|
129
|
+
scrollY: event.scrollY,
|
|
130
|
+
isOpening: event.isOpening,
|
|
131
|
+
isInteractive: event.isInteractive,
|
|
132
|
+
interpolateContentOffsetY: nil
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
self.flushQueuedScrollToEnd()
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private var didScrollToEndInitially = false
|
|
141
|
+
|
|
142
|
+
// MARK: - Inner View
|
|
143
|
+
|
|
144
|
+
/// Custom UIView that notifies owner when added to superview
|
|
145
|
+
/// so we can attach the context to the parent component
|
|
146
|
+
private final class InnerView: UIView {
|
|
147
|
+
weak var owner: HybridAix?
|
|
148
|
+
|
|
149
|
+
override func didMoveToSuperview() {
|
|
150
|
+
super.didMoveToSuperview()
|
|
151
|
+
owner?.handleDidMoveToSuperview()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
|
|
155
|
+
// Never claim to contain any points - let touches pass through
|
|
156
|
+
return false
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/// The root UIView that this context is attached to
|
|
161
|
+
let view: UIView
|
|
162
|
+
|
|
163
|
+
/// Current keyboard height (will be updated by keyboard events)
|
|
164
|
+
var keyboardHeight: CGFloat = 0
|
|
165
|
+
var keyboardHeightWhenOpen: CGFloat = 0
|
|
166
|
+
|
|
167
|
+
/// Tracks whether the app is in the background (to disable keyboard notifications)
|
|
168
|
+
private var isAppInBackground = false
|
|
169
|
+
|
|
170
|
+
// MARK: - Props (from Nitro spec)
|
|
171
|
+
var shouldStartAtEnd: Bool = true
|
|
172
|
+
var scrollOnFooterSizeUpdate: AixScrollOnFooterSizeUpdate?
|
|
173
|
+
var scrollEndReachedThreshold: Double?
|
|
174
|
+
|
|
175
|
+
var keyboardOpenBlankSizeThreshold: Double {
|
|
176
|
+
return 0
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// MARK: - Private Types
|
|
180
|
+
|
|
181
|
+
struct QueuedScrollToEnd {
|
|
182
|
+
var index: Int
|
|
183
|
+
var animated: Bool
|
|
184
|
+
var waitForKeyboardToEnd: Bool
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// MARK: - Private State
|
|
188
|
+
|
|
189
|
+
/// Queued scroll operation waiting for blank view to update
|
|
190
|
+
private var queuedScrollToEnd: QueuedScrollToEnd? = nil
|
|
191
|
+
|
|
192
|
+
/// Registered cells - using NSMapTable for weak references to avoid retain cycles
|
|
193
|
+
/// Key: cell index, Value: weak reference to cell
|
|
194
|
+
private var cells = NSMapTable<NSNumber, HybridAixCellView>.weakToWeakObjects()
|
|
195
|
+
|
|
196
|
+
/// Cached scroll view reference (weak to avoid retain cycles)
|
|
197
|
+
private weak var cachedScrollView: UIScrollView?
|
|
198
|
+
|
|
199
|
+
/// Flag to track if we've set up the pan gesture observer
|
|
200
|
+
private var didSetupPanGestureObserver = false
|
|
201
|
+
|
|
202
|
+
/// Flag to track if we're currently in an interactive keyboard dismiss
|
|
203
|
+
private var isInInteractiveDismiss = false
|
|
204
|
+
|
|
205
|
+
// MARK: - Context References (weak to avoid retain cycles)
|
|
206
|
+
|
|
207
|
+
weak var blankView: HybridAixCellView? = nil {
|
|
208
|
+
didSet {
|
|
209
|
+
// Could add observers or callbacks here when blank view changes
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
weak var composerView: HybridAixComposer? = nil
|
|
214
|
+
|
|
215
|
+
// MARK: - Computed Properties
|
|
216
|
+
|
|
217
|
+
/// Find the scroll view within our view hierarchy
|
|
218
|
+
/// We search from the superview (HybridAixComponent) since the scroll view
|
|
219
|
+
/// is a sibling of our inner view, not a child
|
|
220
|
+
/// If mainScrollViewID is provided, searches by accessibilityIdentifier first
|
|
221
|
+
var scrollView: UIScrollView? {
|
|
222
|
+
if let cached = cachedScrollView {
|
|
223
|
+
return cached
|
|
224
|
+
}
|
|
225
|
+
let searchRoot = view.superview ?? view
|
|
226
|
+
|
|
227
|
+
// If mainScrollViewID is provided, try to find by accessibilityIdentifier first
|
|
228
|
+
var sv: UIScrollView? = nil
|
|
229
|
+
if let scrollViewID = mainScrollViewID, !scrollViewID.isEmpty {
|
|
230
|
+
sv = searchRoot.findScrollView(withIdentifier: scrollViewID)
|
|
231
|
+
if sv != nil {
|
|
232
|
+
print("[Aix] scrollView found by ID '\(scrollViewID)': \(String(describing: sv))")
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Fallback to default subview iteration if not found by ID
|
|
237
|
+
if sv == nil {
|
|
238
|
+
sv = searchRoot.findScrollView()
|
|
239
|
+
print("[Aix] scrollView found by iteration: \(String(describing: sv))")
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
cachedScrollView = sv
|
|
243
|
+
|
|
244
|
+
// Set up pan gesture observer when we find the scroll view
|
|
245
|
+
if let scrollView = sv, !didSetupPanGestureObserver {
|
|
246
|
+
// Disable automatic scroll indicator inset adjustment so we can control it manually
|
|
247
|
+
scrollView.automaticallyAdjustsScrollIndicatorInsets = false
|
|
248
|
+
|
|
249
|
+
setupPanGestureObserver()
|
|
250
|
+
applyScrollIndicatorInsets()
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return sv
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/// Set up observer on scroll view's pan gesture to detect interactive keyboard dismiss
|
|
257
|
+
private func setupPanGestureObserver() {
|
|
258
|
+
guard let scrollView = cachedScrollView else { return }
|
|
259
|
+
didSetupPanGestureObserver = true
|
|
260
|
+
|
|
261
|
+
scrollView.panGestureRecognizer.addTarget(self, action: #selector(handlePanGesture(_:)))
|
|
262
|
+
print("[Aix] Pan gesture observer set up")
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/// Clean up pan gesture observer to avoid retain cycles
|
|
266
|
+
private func removePanGestureObserver() {
|
|
267
|
+
guard didSetupPanGestureObserver, let scrollView = cachedScrollView else { return }
|
|
268
|
+
scrollView.panGestureRecognizer.removeTarget(self, action: #selector(handlePanGesture(_:)))
|
|
269
|
+
didSetupPanGestureObserver = false
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/// Handle pan gesture state changes to detect interactive keyboard dismiss
|
|
273
|
+
@objc private func handlePanGesture(_ gesture: UIPanGestureRecognizer) {
|
|
274
|
+
guard let scrollView = cachedScrollView,
|
|
275
|
+
scrollView.keyboardDismissMode == .interactive,
|
|
276
|
+
keyboardHeight > 0 else { return }
|
|
277
|
+
|
|
278
|
+
switch gesture.state {
|
|
279
|
+
case .began, .changed:
|
|
280
|
+
// Check if finger has reached the top of composer (or keyboard if no composer)
|
|
281
|
+
if !isInInteractiveDismiss && isFingerAtComposerTop(gesture: gesture) {
|
|
282
|
+
startInteractiveKeyboardDismiss()
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
case .ended, .cancelled, .failed:
|
|
286
|
+
if isInInteractiveDismiss {
|
|
287
|
+
// The keyboard manager will handle the end via notifications
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
default:
|
|
291
|
+
break
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/// Check if the finger position has reached the top of the composer view
|
|
296
|
+
private func isFingerAtComposerTop(gesture: UIPanGestureRecognizer) -> Bool {
|
|
297
|
+
guard let window = view.window else { return false }
|
|
298
|
+
|
|
299
|
+
// Get finger location in window coordinates
|
|
300
|
+
let fingerLocationInWindow = gesture.location(in: window)
|
|
301
|
+
|
|
302
|
+
// Get the threshold Y position (top of composer, or top of keyboard if no composer)
|
|
303
|
+
let thresholdY: CGFloat
|
|
304
|
+
if let composerView = composerView?.view, let composerWindow = composerView.window {
|
|
305
|
+
// Convert composer's frame to window coordinates
|
|
306
|
+
let composerFrameInWindow = composerView.convert(composerView.bounds, to: composerWindow)
|
|
307
|
+
thresholdY = composerFrameInWindow.minY
|
|
308
|
+
} else {
|
|
309
|
+
// Fallback: use keyboard top position
|
|
310
|
+
let screenHeight = UIScreen.main.bounds.height
|
|
311
|
+
thresholdY = screenHeight - keyboardHeight
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Finger has reached the composer top when its Y >= threshold
|
|
315
|
+
return fingerLocationInWindow.y >= thresholdY
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/// Start tracking an interactive keyboard dismiss
|
|
319
|
+
private func startInteractiveKeyboardDismiss() {
|
|
320
|
+
return // this is totally broken rn, full of false positives
|
|
321
|
+
guard !isInInteractiveDismiss else { return }
|
|
322
|
+
isInInteractiveDismiss = true
|
|
323
|
+
|
|
324
|
+
let scrollY = scrollView?.contentOffset.y ?? 0
|
|
325
|
+
|
|
326
|
+
print("[Aix] Starting interactive keyboard dismiss from height=\(keyboardHeight), scrollY=\(scrollY)")
|
|
327
|
+
|
|
328
|
+
// Calculate proper interpolation values (same as non-interactive close)
|
|
329
|
+
let interpolation = getContentOffsetYWhenClosing(scrollY: scrollY)
|
|
330
|
+
|
|
331
|
+
startEvent = KeyboardStartEvent(
|
|
332
|
+
scrollY: scrollY,
|
|
333
|
+
isOpening: false,
|
|
334
|
+
isInteractive: true,
|
|
335
|
+
interpolateContentOffsetY: interpolation,
|
|
336
|
+
)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/// Height of the composer view
|
|
340
|
+
private var composerHeight: CGFloat {
|
|
341
|
+
let h = composerView?.view.bounds.height ?? 0
|
|
342
|
+
return max(0, h)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
private var keyboardProgress: Double = 0
|
|
346
|
+
|
|
347
|
+
private var additionalContentInsetTop: CGFloat {
|
|
348
|
+
if let additionalContentInsets, let top = additionalContentInsets.top {
|
|
349
|
+
let interpolate = (top.whenKeyboardClosed, top.whenKeyboardOpen)
|
|
350
|
+
return CGFloat(interpolate.0 + (interpolate.1 - interpolate.0) * keyboardProgress)
|
|
351
|
+
}
|
|
352
|
+
return 0
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
private var additionalContentInsetBottom: CGFloat {
|
|
356
|
+
if let additionalContentInsets, let bottom = additionalContentInsets.bottom {
|
|
357
|
+
let interpolate = (bottom.whenKeyboardClosed, bottom.whenKeyboardOpen)
|
|
358
|
+
return max(0, CGFloat(interpolate.0 + (interpolate.1 - interpolate.0) * keyboardProgress))
|
|
359
|
+
}
|
|
360
|
+
return 0
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
private func calculateBlankSize(keyboardHeight: CGFloat, additionalContentInsetBottom: CGFloat) -> CGFloat {
|
|
364
|
+
guard let scrollView, let blankView else { return 0 }
|
|
365
|
+
|
|
366
|
+
let cellBeforeBlankView = getCell(index: Int(blankView.index) - 1)
|
|
367
|
+
let cellBeforeBlankViewHeight = cellBeforeBlankView?.view.frame.height ?? 0
|
|
368
|
+
let blankViewHeight = blankView.view.frame.height
|
|
369
|
+
|
|
370
|
+
// Calculate visible area above all bottom chrome (keyboard, composer, additional insets)
|
|
371
|
+
// The blank size fills the remaining space so the last message can scroll to the top
|
|
372
|
+
let visibleAreaHeight = scrollView.bounds.height - keyboardHeight - composerHeight - additionalContentInsetBottom
|
|
373
|
+
let inset = visibleAreaHeight - blankViewHeight - cellBeforeBlankViewHeight
|
|
374
|
+
return max(0, inset)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/// Calculate the blank size - the space needed to push content up
|
|
378
|
+
/// so the last message can scroll to the top of the visible area
|
|
379
|
+
var blankSize: CGFloat {
|
|
380
|
+
return calculateBlankSize(keyboardHeight: keyboardHeight, additionalContentInsetBottom: additionalContentInsetBottom)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/// The content inset for the bottom of the scroll view
|
|
384
|
+
|
|
385
|
+
private func calculateContentInsetBottom(keyboardHeight: CGFloat, blankSize: CGFloat, additionalContentInsetBottom: CGFloat) -> CGFloat {
|
|
386
|
+
return blankSize + keyboardHeight + composerHeight + additionalContentInsetBottom
|
|
387
|
+
}
|
|
388
|
+
var contentInsetBottom: CGFloat {
|
|
389
|
+
return calculateContentInsetBottom(keyboardHeight: self.keyboardHeight, blankSize: self.blankSize, additionalContentInsetBottom: self.additionalContentInsetBottom)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/// Apply the current content inset to the scroll view
|
|
393
|
+
func applyContentInset(contentInsetBottom overrideContentInsetBottom: CGFloat? = nil) {
|
|
394
|
+
guard let scrollView else { return }
|
|
395
|
+
|
|
396
|
+
let targetTop = additionalContentInsetTop
|
|
397
|
+
if scrollView.contentInset.top != targetTop {
|
|
398
|
+
scrollView.contentInset.top = targetTop
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
let targetBottom = overrideContentInsetBottom ?? self.contentInsetBottom
|
|
402
|
+
if scrollView.contentInset.bottom != targetBottom {
|
|
403
|
+
scrollView.contentInset.bottom = targetBottom
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/// Apply scroll indicator insets to the scroll view
|
|
408
|
+
/// Includes keyboard height, composer height, and additional insets from props
|
|
409
|
+
func applyScrollIndicatorInsets() {
|
|
410
|
+
guard let scrollView else { return }
|
|
411
|
+
|
|
412
|
+
// Calculate additional top inset based on keyboard progress
|
|
413
|
+
var additionalTop: CGFloat = 0
|
|
414
|
+
if let insets = additionalScrollIndicatorInsets, let top = insets.top {
|
|
415
|
+
additionalTop = CGFloat(top.whenKeyboardClosed + (top.whenKeyboardOpen - top.whenKeyboardClosed) * keyboardProgress)
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Calculate additional bottom inset based on keyboard progress
|
|
419
|
+
var additionalBottom: CGFloat = 0
|
|
420
|
+
if let insets = additionalScrollIndicatorInsets, let bottom = insets.bottom {
|
|
421
|
+
additionalBottom = CGFloat(bottom.whenKeyboardClosed + (bottom.whenKeyboardOpen - bottom.whenKeyboardClosed) * keyboardProgress)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Bottom inset: keyboard + composer + additional
|
|
425
|
+
// Note: Don't add safe area here - the footer handles its own safe area padding internally
|
|
426
|
+
let bottomInset = keyboardHeight + composerHeight + additionalBottom
|
|
427
|
+
|
|
428
|
+
let newInsets = UIEdgeInsets(
|
|
429
|
+
top: additionalTop,
|
|
430
|
+
left: 0,
|
|
431
|
+
bottom: bottomInset,
|
|
432
|
+
right: 0
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
if scrollView.verticalScrollIndicatorInsets != newInsets {
|
|
436
|
+
scrollView.verticalScrollIndicatorInsets = newInsets
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
private func scrollToEndInternal(animated: Bool?) {
|
|
441
|
+
guard let scrollView else { return }
|
|
442
|
+
|
|
443
|
+
// Calculate the offset to show the bottom of content
|
|
444
|
+
let bottomOffset = CGPoint(
|
|
445
|
+
x: 0,
|
|
446
|
+
y: max(0, scrollView.contentSize.height - scrollView.bounds.height + self.contentInsetBottom)
|
|
447
|
+
)
|
|
448
|
+
scrollView.setContentOffset(bottomOffset, animated: animated ?? true)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
// MARK: - Keyboard Observer (notification-based)
|
|
453
|
+
|
|
454
|
+
private lazy var keyboardNotifications: KeyboardNotifications = {
|
|
455
|
+
return KeyboardNotifications(notifications: [.willShow, .willHide, .didShow, .didHide, .willChangeFrame], delegate: self)
|
|
456
|
+
}()
|
|
457
|
+
|
|
458
|
+
/// Event captured at the start of a keyboard transition
|
|
459
|
+
private struct KeyboardStartEvent {
|
|
460
|
+
let scrollY: CGFloat
|
|
461
|
+
let isOpening: Bool
|
|
462
|
+
var isInteractive: Bool
|
|
463
|
+
let interpolateContentOffsetY: (CGFloat, CGFloat)?
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/// Current keyboard start event (nil when no keyboard transition is active)
|
|
467
|
+
private var startEvent: KeyboardStartEvent? = nil
|
|
468
|
+
|
|
469
|
+
/// Handle keyboard will move (start of animation)
|
|
470
|
+
private func handleKeyboardWillMove(targetHeight: CGFloat, isOpening: Bool) {
|
|
471
|
+
// Capture the target height when keyboard is opening
|
|
472
|
+
if isOpening && targetHeight > keyboardHeightWhenOpen {
|
|
473
|
+
keyboardHeightWhenOpen = targetHeight
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// If we're already in an interactive dismiss, don't overwrite
|
|
477
|
+
if isInInteractiveDismiss {
|
|
478
|
+
return
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
let scrollY = scrollView?.contentOffset.y ?? 0
|
|
482
|
+
|
|
483
|
+
var interpolateContentOffsetY: (CGFloat, CGFloat)? = {
|
|
484
|
+
if isOpening {
|
|
485
|
+
return self.getContentOffsetYWhenOpening(scrollY: scrollY)
|
|
486
|
+
} else {
|
|
487
|
+
return self.getContentOffsetYWhenClosing(scrollY: scrollY)
|
|
488
|
+
}
|
|
489
|
+
}()
|
|
490
|
+
|
|
491
|
+
if queuedScrollToEnd != nil {
|
|
492
|
+
// don't interpolate the keyboard if we're planning to scroll to end
|
|
493
|
+
interpolateContentOffsetY = nil
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
print("[Aix] handleKeyboardWillMove: isOpening=\(isOpening), interpolate=\(String(describing: interpolateContentOffsetY))")
|
|
497
|
+
|
|
498
|
+
startEvent = KeyboardStartEvent(
|
|
499
|
+
scrollY: scrollY,
|
|
500
|
+
isOpening: isOpening,
|
|
501
|
+
isInteractive: false,
|
|
502
|
+
interpolateContentOffsetY: interpolateContentOffsetY
|
|
503
|
+
)
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/// Handle keyboard frame updates during animation
|
|
507
|
+
private func handleKeyboardMove(height: CGFloat, progress: CGFloat) {
|
|
508
|
+
if keyboardHeightWhenOpen > 0 {
|
|
509
|
+
keyboardProgress = height / keyboardHeightWhenOpen
|
|
510
|
+
}
|
|
511
|
+
keyboardHeight = height
|
|
512
|
+
|
|
513
|
+
guard let startEvent else { return }
|
|
514
|
+
|
|
515
|
+
applyContentInset()
|
|
516
|
+
applyScrollIndicatorInsets()
|
|
517
|
+
|
|
518
|
+
if let (startY, endY) = startEvent.interpolateContentOffsetY {
|
|
519
|
+
// Normalize progress to always go from 0 to 1 (start to end)
|
|
520
|
+
// For opening: progress goes 0→1, so use as-is
|
|
521
|
+
// For closing: progress goes 1→0, so invert it
|
|
522
|
+
let normalizedProgress = startEvent.isOpening ? progress : (1 - progress)
|
|
523
|
+
let newScrollY = startY + (endY - startY) * normalizedProgress
|
|
524
|
+
scrollView?.setContentOffset(CGPoint(x: 0, y: newScrollY), animated: false)
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/// Handle keyboard animation end
|
|
529
|
+
private func handleKeyboardDidMove(height: CGFloat, progress: CGFloat) {
|
|
530
|
+
// Ensure final height is applied
|
|
531
|
+
keyboardHeight = height
|
|
532
|
+
if keyboardHeightWhenOpen > 0 {
|
|
533
|
+
keyboardProgress = height / keyboardHeightWhenOpen
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
applyContentInset()
|
|
537
|
+
applyScrollIndicatorInsets()
|
|
538
|
+
|
|
539
|
+
startEvent = nil
|
|
540
|
+
isInInteractiveDismiss = false
|
|
541
|
+
|
|
542
|
+
if queuedScrollToEnd?.waitForKeyboardToEnd == true {
|
|
543
|
+
flushQueuedScrollToEnd(force: true)
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/// Handle interactive keyboard dismissal
|
|
548
|
+
private func handleKeyboardMoveInteractive(height: CGFloat, progress: CGFloat) {
|
|
549
|
+
// Mark that we're in an interactive dismiss if not already
|
|
550
|
+
if !isInInteractiveDismiss && startEvent != nil {
|
|
551
|
+
isInInteractiveDismiss = true
|
|
552
|
+
if var event = startEvent {
|
|
553
|
+
event.isInteractive = true
|
|
554
|
+
startEvent = event
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Update keyboard state
|
|
559
|
+
handleKeyboardMove(height: height, progress: progress)
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// MARK: - Initialization
|
|
563
|
+
|
|
564
|
+
override init() {
|
|
565
|
+
let inner = InnerView()
|
|
566
|
+
self.view = inner
|
|
567
|
+
super.init()
|
|
568
|
+
inner.owner = self
|
|
569
|
+
print("[Aix] HybridAix initialized, attaching context to view")
|
|
570
|
+
// Attach this context to our inner view
|
|
571
|
+
view.aixContext = self
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
deinit {
|
|
575
|
+
removePanGestureObserver()
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// MARK: - Lifecycle
|
|
579
|
+
|
|
580
|
+
/// Called when our view is added to or removed from the HybridAixComponent
|
|
581
|
+
private func handleDidMoveToSuperview() {
|
|
582
|
+
if let superview = view.superview {
|
|
583
|
+
print("[Aix] View added to superview: \(type(of: superview)), attaching context")
|
|
584
|
+
// Attach context to the superview (HybridAixComponent) so children can find it
|
|
585
|
+
superview.aixContext = self
|
|
586
|
+
|
|
587
|
+
// Enable keyboard notifications (unless app is in background)
|
|
588
|
+
if !isAppInBackground {
|
|
589
|
+
keyboardNotifications.isEnabled = true
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Add app lifecycle observers
|
|
593
|
+
NotificationCenter.default.addObserver(
|
|
594
|
+
self,
|
|
595
|
+
selector: #selector(handleAppDidEnterBackground),
|
|
596
|
+
name: UIApplication.didEnterBackgroundNotification,
|
|
597
|
+
object: nil
|
|
598
|
+
)
|
|
599
|
+
NotificationCenter.default.addObserver(
|
|
600
|
+
self,
|
|
601
|
+
selector: #selector(handleAppWillEnterForeground),
|
|
602
|
+
name: UIApplication.willEnterForegroundNotification,
|
|
603
|
+
object: nil
|
|
604
|
+
)
|
|
605
|
+
} else {
|
|
606
|
+
// View removed from superview - disable keyboard notifications
|
|
607
|
+
keyboardNotifications.isEnabled = false
|
|
608
|
+
|
|
609
|
+
// Remove app lifecycle observers
|
|
610
|
+
NotificationCenter.default.removeObserver(self, name: UIApplication.didEnterBackgroundNotification, object: nil)
|
|
611
|
+
NotificationCenter.default.removeObserver(self, name: UIApplication.willEnterForegroundNotification, object: nil)
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
@objc private func handleAppDidEnterBackground() {
|
|
616
|
+
isAppInBackground = true
|
|
617
|
+
keyboardNotifications.isEnabled = false
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
@objc private func handleAppWillEnterForeground() {
|
|
621
|
+
isAppInBackground = false
|
|
622
|
+
// Only re-enable if view is still in a superview
|
|
623
|
+
if view.superview != nil {
|
|
624
|
+
keyboardNotifications.isEnabled = true
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// MARK: - AixContext Protocol
|
|
629
|
+
|
|
630
|
+
private var lastReportedBlankViewSize = (size: CGSize.zero, index: 0)
|
|
631
|
+
|
|
632
|
+
func reportBlankViewSizeChange(size: CGSize, index: Int) {
|
|
633
|
+
let didAlreadyUpdate = size.height == lastReportedBlankViewSize.size.height && size.width == lastReportedBlankViewSize.size.width && index == lastReportedBlankViewSize.index
|
|
634
|
+
if didAlreadyUpdate {
|
|
635
|
+
return
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
lastReportedBlankViewSize = (size: size, index: index)
|
|
639
|
+
|
|
640
|
+
// Check if we have a queued scroll waiting for this index
|
|
641
|
+
if !didScrollToEndInitially {
|
|
642
|
+
UIView.performWithoutAnimation {
|
|
643
|
+
applyContentInset()
|
|
644
|
+
applyScrollIndicatorInsets()
|
|
645
|
+
scrollToEndInternal(animated: false)
|
|
646
|
+
}
|
|
647
|
+
didScrollToEndInitially = true
|
|
648
|
+
} else {
|
|
649
|
+
applyContentInset()
|
|
650
|
+
applyScrollIndicatorInsets()
|
|
651
|
+
|
|
652
|
+
if let queued = queuedScrollToEnd, index == queued.index {
|
|
653
|
+
flushQueuedScrollToEnd()
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
func registerCell(_ cell: HybridAixCellView) {
|
|
659
|
+
cells.setObject(cell, forKey: NSNumber(value: cell.index))
|
|
660
|
+
|
|
661
|
+
// If this cell is marked as last, update our blank view reference
|
|
662
|
+
if cell.isLast {
|
|
663
|
+
blankView = cell
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
func unregisterCell(_ cell: HybridAixCellView) {
|
|
668
|
+
cells.removeObject(forKey: NSNumber(value: cell.index))
|
|
669
|
+
|
|
670
|
+
// If this was our blank view, clear it
|
|
671
|
+
if blankView === cell {
|
|
672
|
+
blankView = nil
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
func registerComposerView(_ composerView: HybridAixComposer) {
|
|
677
|
+
self.composerView = composerView
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
func unregisterComposerView(_ composerView: HybridAixComposer) {
|
|
681
|
+
if self.composerView === composerView {
|
|
682
|
+
self.composerView = nil
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
private var lastReportedComposerHeight: CGFloat = 0
|
|
687
|
+
|
|
688
|
+
func reportComposerHeightChange(height: CGFloat) {
|
|
689
|
+
if height == lastReportedComposerHeight {
|
|
690
|
+
return
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
let previousHeight = lastReportedComposerHeight
|
|
694
|
+
let isShrinking = height < previousHeight
|
|
695
|
+
|
|
696
|
+
lastReportedComposerHeight = height
|
|
697
|
+
|
|
698
|
+
if !didScrollToEndInitially {
|
|
699
|
+
return
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
let shouldScroll = shouldScrollOnFooterSizeUpdate()
|
|
703
|
+
let animated = scrollOnFooterSizeUpdate?.animated ?? false
|
|
704
|
+
|
|
705
|
+
if shouldScroll && animated && isShrinking {
|
|
706
|
+
guard let scrollView else {
|
|
707
|
+
applyContentInset()
|
|
708
|
+
applyScrollIndicatorInsets()
|
|
709
|
+
return
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
let newContentInsetBottom = self.contentInsetBottom
|
|
713
|
+
let bottomOffset = CGPoint(
|
|
714
|
+
x: 0,
|
|
715
|
+
y: max(0, scrollView.contentSize.height - scrollView.bounds.height + newContentInsetBottom)
|
|
716
|
+
)
|
|
717
|
+
|
|
718
|
+
UIView.animate(withDuration: 0.25, delay: 0, options: [.curveEaseOut]) {
|
|
719
|
+
scrollView.contentInset.bottom = newContentInsetBottom
|
|
720
|
+
scrollView.contentOffset = bottomOffset
|
|
721
|
+
}
|
|
722
|
+
applyScrollIndicatorInsets()
|
|
723
|
+
} else {
|
|
724
|
+
applyContentInset()
|
|
725
|
+
applyScrollIndicatorInsets()
|
|
726
|
+
|
|
727
|
+
if shouldScroll {
|
|
728
|
+
scrollToEndInternal(animated: animated)
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
private func shouldScrollOnFooterSizeUpdate() -> Bool {
|
|
734
|
+
guard let settings = scrollOnFooterSizeUpdate, settings.enabled else {
|
|
735
|
+
return false
|
|
736
|
+
}
|
|
737
|
+
guard let scrollView else {
|
|
738
|
+
return false
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
let contentHeight = scrollView.contentSize.height
|
|
742
|
+
let scrollViewHeight = scrollView.bounds.height
|
|
743
|
+
let currentOffsetY = scrollView.contentOffset.y
|
|
744
|
+
let bottomInset = scrollView.contentInset.bottom
|
|
745
|
+
|
|
746
|
+
let maxOffsetY = max(0, contentHeight - scrollViewHeight + bottomInset)
|
|
747
|
+
let distanceFromEnd = maxOffsetY - currentOffsetY
|
|
748
|
+
|
|
749
|
+
let threshold = settings.scrolledToEndThreshold ?? 0
|
|
750
|
+
return distanceFromEnd <= CGFloat(threshold)
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// MARK: - Cell Access
|
|
754
|
+
|
|
755
|
+
/// Get a cell by its index
|
|
756
|
+
func getCell(index: Int) -> HybridAixCellView? {
|
|
757
|
+
return cells.object(forKey: NSNumber(value: index))
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
// MARK: - Scrolling
|
|
762
|
+
|
|
763
|
+
func getIsQueuedScrollToEndReady(queuedScrollToEnd: QueuedScrollToEnd) -> Bool {
|
|
764
|
+
guard let blankView else { return false }
|
|
765
|
+
if queuedScrollToEnd.waitForKeyboardToEnd == true && startEvent != nil {
|
|
766
|
+
return false
|
|
767
|
+
}
|
|
768
|
+
return blankView.isLast && queuedScrollToEnd.index == Int(blankView.index)
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
func flushQueuedScrollToEnd(force: Bool = false) {
|
|
773
|
+
if let queuedScrollToEnd, (force || getIsQueuedScrollToEndReady(queuedScrollToEnd: queuedScrollToEnd)) {
|
|
774
|
+
scrollToEndInternal(animated: queuedScrollToEnd.animated)
|
|
775
|
+
self.queuedScrollToEnd = nil
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// MARK: - Keyboard Notification Handlers
|
|
780
|
+
|
|
781
|
+
func keyboardWillShow(notification: NSNotification) {
|
|
782
|
+
guard let userInfo = notification.userInfo,
|
|
783
|
+
let keyboardFrame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect,
|
|
784
|
+
let duration = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double,
|
|
785
|
+
let curveValue = userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? UInt else { return }
|
|
786
|
+
|
|
787
|
+
let targetHeight = keyboardFrame.height
|
|
788
|
+
print("[Aix] keyboardWillShow: targetHeight=\(targetHeight), duration=\(duration)")
|
|
789
|
+
|
|
790
|
+
guard duration > 0 else { return }
|
|
791
|
+
|
|
792
|
+
if targetHeight > keyboardHeightWhenOpen {
|
|
793
|
+
keyboardHeightWhenOpen = targetHeight
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
if !didScrollToEndInitially {
|
|
797
|
+
keyboardHeight = targetHeight
|
|
798
|
+
keyboardProgress = 1.0
|
|
799
|
+
applyContentInset()
|
|
800
|
+
applyScrollIndicatorInsets()
|
|
801
|
+
return
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
handleKeyboardWillMove(targetHeight: targetHeight, isOpening: true)
|
|
805
|
+
|
|
806
|
+
let options = UIView.AnimationOptions(rawValue: curveValue << 16)
|
|
807
|
+
UIView.animate(withDuration: duration, delay: 0, options: options, animations: { [weak self] in
|
|
808
|
+
guard let self = self else { return }
|
|
809
|
+
self.keyboardHeight = targetHeight
|
|
810
|
+
if self.keyboardHeightWhenOpen > 0 {
|
|
811
|
+
self.keyboardProgress = targetHeight / self.keyboardHeightWhenOpen
|
|
812
|
+
}
|
|
813
|
+
self.applyContentInset()
|
|
814
|
+
self.applyScrollIndicatorInsets()
|
|
815
|
+
|
|
816
|
+
if let (startY, endY) = self.startEvent?.interpolateContentOffsetY {
|
|
817
|
+
self.scrollView?.setContentOffset(CGPoint(x: 0, y: endY), animated: false)
|
|
818
|
+
}
|
|
819
|
+
}, completion: { [weak self] _ in
|
|
820
|
+
self?.handleKeyboardDidMove(height: targetHeight, progress: 1.0)
|
|
821
|
+
})
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
func keyboardWillHide(notification: NSNotification) {
|
|
825
|
+
guard let userInfo = notification.userInfo,
|
|
826
|
+
let duration = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double,
|
|
827
|
+
let curveValue = userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? UInt else { return }
|
|
828
|
+
|
|
829
|
+
print("[Aix] keyboardWillHide: duration=\(duration)")
|
|
830
|
+
|
|
831
|
+
// Don't interpolate scroll position when closing, the inset change will handle the visual transition
|
|
832
|
+
startEvent = nil
|
|
833
|
+
|
|
834
|
+
let options = UIView.AnimationOptions(rawValue: curveValue << 16)
|
|
835
|
+
UIView.animate(withDuration: duration, delay: 0, options: options, animations: { [weak self] in
|
|
836
|
+
guard let self = self else { return }
|
|
837
|
+
|
|
838
|
+
self.keyboardHeight = 0
|
|
839
|
+
self.keyboardProgress = 0
|
|
840
|
+
self.applyContentInset()
|
|
841
|
+
self.applyScrollIndicatorInsets()
|
|
842
|
+
}, completion: { [weak self] _ in
|
|
843
|
+
self?.handleKeyboardDidMove(height: 0, progress: 0)
|
|
844
|
+
})
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
func keyboardDidShow(notification: NSNotification) {
|
|
848
|
+
print("[Aix] keyboardDidShow")
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
func keyboardDidHide(notification: NSNotification) {
|
|
852
|
+
print("[Aix] keyboardDidHide")
|
|
853
|
+
keyboardHeightWhenOpen = 0
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
func keyboardWillChangeFrame(notification: NSNotification) {
|
|
857
|
+
guard let userInfo = notification.userInfo,
|
|
858
|
+
let keyboardFrameEnd = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return }
|
|
859
|
+
|
|
860
|
+
let screenHeight = UIScreen.main.bounds.height
|
|
861
|
+
let keyboardTop = keyboardFrameEnd.origin.y
|
|
862
|
+
let newHeight = max(0, screenHeight - keyboardTop)
|
|
863
|
+
|
|
864
|
+
if startEvent != nil && !isInInteractiveDismiss {
|
|
865
|
+
return
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
if isInInteractiveDismiss && newHeight != keyboardHeight {
|
|
869
|
+
let progress = keyboardHeightWhenOpen > 0 ? newHeight / keyboardHeightWhenOpen : 0
|
|
870
|
+
handleKeyboardMoveInteractive(height: newHeight, progress: progress)
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// MARK: - Scroll Position Helpers
|
|
876
|
+
|
|
877
|
+
extension HybridAix {
|
|
878
|
+
/// Check if an interactive keyboard dismiss is in progress by examining scroll view state
|
|
879
|
+
private func isInteractiveDismissInProgress() -> Bool {
|
|
880
|
+
guard let scrollView = scrollView else { return false }
|
|
881
|
+
|
|
882
|
+
// Check if scroll view has interactive keyboard dismiss mode
|
|
883
|
+
guard scrollView.keyboardDismissMode == .interactive else { return false }
|
|
884
|
+
|
|
885
|
+
// Check if pan gesture is active (user is scrolling)
|
|
886
|
+
let panGesture = scrollView.panGestureRecognizer
|
|
887
|
+
let gestureState = panGesture.state
|
|
888
|
+
|
|
889
|
+
// Pan gesture states: .began = 1, .changed = 2
|
|
890
|
+
return gestureState == .began || gestureState == .changed
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/// Distance from current scroll position to the maximum scroll position (end)
|
|
894
|
+
var distFromEnd: CGFloat {
|
|
895
|
+
guard let scrollView = scrollView else { return 0 }
|
|
896
|
+
let maxScrollY = scrollView.contentSize.height - scrollView.bounds.height + contentInsetBottom
|
|
897
|
+
return maxScrollY - scrollView.contentOffset.y
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
func getIsScrolledNearEnd(distFromEnd: CGFloat) -> Bool {
|
|
901
|
+
return distFromEnd <= (scrollEndReachedThreshold ?? max(200, blankSize))
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
func getContentOffsetYWhenOpening(scrollY: CGFloat) -> (CGFloat, CGFloat)? {
|
|
905
|
+
guard let scrollView else { return nil }
|
|
906
|
+
let isScrolledNearEnd = getIsScrolledNearEnd(distFromEnd: distFromEnd)
|
|
907
|
+
let shouldShiftContentUp = blankSize == 0 && isScrolledNearEnd
|
|
908
|
+
|
|
909
|
+
// Use the target additionalContentInsetBottom when keyboard is fully open
|
|
910
|
+
let targetAdditionalInset = CGFloat(self.additionalContentInsets?.bottom?.whenKeyboardOpen ?? 0)
|
|
911
|
+
|
|
912
|
+
// Calculate the max scroll position when keyboard is open
|
|
913
|
+
// This is where we want to scroll to: contentSize - bounds + contentInset
|
|
914
|
+
// When blankSize is 0: contentInset = keyboard + composer + additionalInset
|
|
915
|
+
let shiftContentUpToY = scrollView.contentSize.height - scrollView.bounds.height + keyboardHeightWhenOpen + composerHeight + targetAdditionalInset
|
|
916
|
+
|
|
917
|
+
if shouldShiftContentUp {
|
|
918
|
+
return (scrollY, shiftContentUpToY)
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
let hasBlankSizeLessThanOpenKeyboardHeight = blankSize > 0 && blankSize <= keyboardHeightWhenOpen
|
|
922
|
+
|
|
923
|
+
if hasBlankSizeLessThanOpenKeyboardHeight && isScrolledNearEnd {
|
|
924
|
+
return (scrollY, shiftContentUpToY)
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
return nil
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
func getContentOffsetYWhenClosing(scrollY: CGFloat) -> (CGFloat, CGFloat)? {
|
|
931
|
+
guard keyboardHeightWhenOpen > 0 else { return nil }
|
|
932
|
+
let isScrolledNearEnd = getIsScrolledNearEnd(distFromEnd: distFromEnd)
|
|
933
|
+
|
|
934
|
+
if !isScrolledNearEnd {
|
|
935
|
+
return nil
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
let additionalContentInsetBottomWithKeyboard = CGFloat(self.additionalContentInsets?.bottom?.whenKeyboardOpen ?? 0)
|
|
939
|
+
let additionalContentInsetBottomWithoutKeyboard = CGFloat(self.additionalContentInsets?.bottom?.whenKeyboardClosed ?? 0)
|
|
940
|
+
|
|
941
|
+
// Calculate how much content inset will decrease when keyboard closes
|
|
942
|
+
let blankSizeWithKeyboard = calculateBlankSize(keyboardHeight: keyboardHeightWhenOpen, additionalContentInsetBottom: additionalContentInsetBottomWithKeyboard)
|
|
943
|
+
let blankSizeWithoutKeyboard = calculateBlankSize(keyboardHeight: 0, additionalContentInsetBottom: additionalContentInsetBottomWithoutKeyboard)
|
|
944
|
+
|
|
945
|
+
// Calculate actual content insets (including composer)
|
|
946
|
+
let insetWithKeyboard = calculateContentInsetBottom(keyboardHeight: keyboardHeightWhenOpen, blankSize: blankSizeWithKeyboard, additionalContentInsetBottom: additionalContentInsetBottomWithKeyboard)
|
|
947
|
+
let insetWithoutKeyboard = calculateContentInsetBottom(keyboardHeight: 0, blankSize: blankSizeWithoutKeyboard,
|
|
948
|
+
additionalContentInsetBottom: additionalContentInsetBottomWithoutKeyboard
|
|
949
|
+
)
|
|
950
|
+
let insetDecrease = insetWithKeyboard - insetWithoutKeyboard
|
|
951
|
+
|
|
952
|
+
// To keep the visual content position stable, we need to decrease scrollY
|
|
953
|
+
// by the same amount the inset decreases
|
|
954
|
+
let targetScrollY = max(0, scrollY - insetDecrease)
|
|
955
|
+
|
|
956
|
+
// Only interpolate if there's actually movement needed
|
|
957
|
+
guard abs(scrollY - targetScrollY) > 1 else { return nil }
|
|
958
|
+
|
|
959
|
+
return (scrollY, targetScrollY)
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// Source - https://stackoverflow.com/a
|
|
964
|
+
// Posted by Vasily Bodnarchuk, modified by community. See post 'Timeline' for change history
|
|
965
|
+
// Retrieved 2026-01-07, License - CC BY-SA 4.0
|
|
966
|
+
|
|
967
|
+
protocol KeyboardNotificationsDelegate: AnyObject {
|
|
968
|
+
func keyboardWillShow(notification: NSNotification)
|
|
969
|
+
func keyboardWillHide(notification: NSNotification)
|
|
970
|
+
func keyboardDidShow(notification: NSNotification)
|
|
971
|
+
func keyboardDidHide(notification: NSNotification)
|
|
972
|
+
func keyboardWillChangeFrame(notification: NSNotification)
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
extension KeyboardNotificationsDelegate {
|
|
976
|
+
func keyboardWillShow(notification: NSNotification) {}
|
|
977
|
+
func keyboardWillHide(notification: NSNotification) {}
|
|
978
|
+
func keyboardDidShow(notification: NSNotification) {}
|
|
979
|
+
func keyboardDidHide(notification: NSNotification) {}
|
|
980
|
+
func keyboardWillChangeFrame(notification: NSNotification) {}
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
class KeyboardNotifications {
|
|
984
|
+
fileprivate var _isEnabled: Bool
|
|
985
|
+
fileprivate var notifications: [KeyboardNotificationsType]
|
|
986
|
+
fileprivate weak var delegate: KeyboardNotificationsDelegate?
|
|
987
|
+
|
|
988
|
+
init(notifications: [KeyboardNotificationsType], delegate: KeyboardNotificationsDelegate) {
|
|
989
|
+
_isEnabled = false
|
|
990
|
+
self.notifications = notifications
|
|
991
|
+
self.delegate = delegate
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
deinit { if isEnabled { isEnabled = false } }
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// MARK: - enums
|
|
998
|
+
|
|
999
|
+
extension KeyboardNotifications {
|
|
1000
|
+
|
|
1001
|
+
enum KeyboardNotificationsType {
|
|
1002
|
+
case willShow, willHide, didShow, didHide, willChangeFrame
|
|
1003
|
+
|
|
1004
|
+
var selector: Selector {
|
|
1005
|
+
switch self {
|
|
1006
|
+
case .willShow: return #selector(keyboardWillShow(notification:))
|
|
1007
|
+
case .willHide: return #selector(keyboardWillHide(notification:))
|
|
1008
|
+
case .didShow: return #selector(keyboardDidShow(notification:))
|
|
1009
|
+
case .didHide: return #selector(keyboardDidHide(notification:))
|
|
1010
|
+
case .willChangeFrame: return #selector(keyboardWillChangeFrame(notification:))
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
var notificationName: NSNotification.Name {
|
|
1015
|
+
switch self {
|
|
1016
|
+
case .willShow: return UIResponder.keyboardWillShowNotification
|
|
1017
|
+
case .willHide: return UIResponder.keyboardWillHideNotification
|
|
1018
|
+
case .didShow: return UIResponder.keyboardDidShowNotification
|
|
1019
|
+
case .didHide: return UIResponder.keyboardDidHideNotification
|
|
1020
|
+
case .willChangeFrame: return UIResponder.keyboardWillChangeFrameNotification
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// MARK: - isEnabled
|
|
1027
|
+
|
|
1028
|
+
extension KeyboardNotifications {
|
|
1029
|
+
|
|
1030
|
+
private func addObserver(type: KeyboardNotificationsType) {
|
|
1031
|
+
NotificationCenter.default.addObserver(self, selector: type.selector, name: type.notificationName, object: nil)
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
var isEnabled: Bool {
|
|
1035
|
+
set {
|
|
1036
|
+
if newValue {
|
|
1037
|
+
for notificaton in notifications { addObserver(type: notificaton) }
|
|
1038
|
+
} else {
|
|
1039
|
+
NotificationCenter.default.removeObserver(self)
|
|
1040
|
+
}
|
|
1041
|
+
_isEnabled = newValue
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
get { return _isEnabled }
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// MARK: - Notification functions
|
|
1050
|
+
|
|
1051
|
+
extension KeyboardNotifications {
|
|
1052
|
+
|
|
1053
|
+
@objc func keyboardWillShow(notification: NSNotification) {
|
|
1054
|
+
delegate?.keyboardWillShow(notification: notification)
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
@objc func keyboardWillHide(notification: NSNotification) {
|
|
1058
|
+
delegate?.keyboardWillHide(notification: notification)
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
@objc func keyboardDidShow(notification: NSNotification) {
|
|
1062
|
+
delegate?.keyboardDidShow(notification: notification)
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
@objc func keyboardDidHide(notification: NSNotification) {
|
|
1066
|
+
delegate?.keyboardDidHide(notification: notification)
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
@objc func keyboardWillChangeFrame(notification: NSNotification) {
|
|
1070
|
+
delegate?.keyboardWillChangeFrame(notification: notification)
|
|
1071
|
+
}
|
|
1072
|
+
}
|