aix 0.0.15 → 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.
Files changed (241) hide show
  1. package/Aix.podspec +32 -0
  2. package/LICENSE +2 -2
  3. package/README.md +165 -33
  4. package/android/CMakeLists.txt +32 -0
  5. package/android/build.gradle +148 -0
  6. package/android/fix-prefab.gradle +51 -0
  7. package/android/gradle.properties +5 -0
  8. package/android/src/main/AndroidManifest.xml +2 -0
  9. package/android/src/main/cpp/cpp-adapter.cpp +6 -0
  10. package/android/src/main/java/com/aix/AixPackage.kt +29 -0
  11. package/android/src/main/java/com/aix/HybridAix.kt +27 -0
  12. package/ios/Bridge.h +8 -0
  13. package/ios/HybridAix.swift +1072 -0
  14. package/ios/HybridAixCellView.swift +174 -0
  15. package/ios/HybridAixComposer.swift +119 -0
  16. package/lib/commonjs/aix.js +25 -0
  17. package/lib/commonjs/aix.js.map +1 -0
  18. package/lib/commonjs/fade-in/createUsePool.js +50 -0
  19. package/lib/commonjs/fade-in/createUsePool.js.map +1 -0
  20. package/lib/commonjs/fade-in/createUseStaggered.js +82 -0
  21. package/lib/commonjs/fade-in/createUseStaggered.js.map +1 -0
  22. package/lib/commonjs/fade-in/index.js +78 -0
  23. package/lib/commonjs/fade-in/index.js.map +1 -0
  24. package/lib/commonjs/footer.js +28 -0
  25. package/lib/commonjs/footer.js.map +1 -0
  26. package/lib/commonjs/index.js +48 -0
  27. package/lib/commonjs/index.js.map +1 -0
  28. package/lib/commonjs/package.json +1 -0
  29. package/lib/commonjs/views/aix.nitro.js +6 -0
  30. package/lib/commonjs/views/aix.nitro.js.map +1 -0
  31. package/lib/module/aix.js +20 -0
  32. package/lib/module/aix.js.map +1 -0
  33. package/lib/module/fade-in/createUsePool.js +46 -0
  34. package/lib/module/fade-in/createUsePool.js.map +1 -0
  35. package/lib/module/fade-in/createUseStaggered.js +79 -0
  36. package/lib/module/fade-in/createUseStaggered.js.map +1 -0
  37. package/lib/module/fade-in/index.js +74 -0
  38. package/lib/module/fade-in/index.js.map +1 -0
  39. package/lib/module/footer.js +23 -0
  40. package/lib/module/footer.js.map +1 -0
  41. package/lib/module/index.js +13 -0
  42. package/lib/module/index.js.map +1 -0
  43. package/lib/module/package.json +1 -0
  44. package/lib/module/views/aix.nitro.js +4 -0
  45. package/lib/module/views/aix.nitro.js.map +1 -0
  46. package/lib/typescript/src/aix.d.ts +14 -0
  47. package/lib/typescript/src/aix.d.ts.map +1 -0
  48. package/lib/typescript/src/fade-in/createUsePool.d.ts +5 -0
  49. package/lib/typescript/src/fade-in/createUsePool.d.ts.map +1 -0
  50. package/lib/typescript/src/fade-in/createUseStaggered.d.ts +2 -0
  51. package/lib/typescript/src/fade-in/createUseStaggered.d.ts.map +1 -0
  52. package/lib/typescript/src/fade-in/index.d.ts +5 -0
  53. package/lib/typescript/src/fade-in/index.d.ts.map +1 -0
  54. package/lib/typescript/src/footer.d.ts +5 -0
  55. package/lib/typescript/src/footer.d.ts.map +1 -0
  56. package/lib/typescript/src/index.d.ts +10 -0
  57. package/lib/typescript/src/index.d.ts.map +1 -0
  58. package/lib/typescript/src/views/aix.nitro.d.ts +101 -0
  59. package/lib/typescript/src/views/aix.nitro.d.ts.map +1 -0
  60. package/nitro.json +26 -0
  61. package/nitrogen/generated/.gitattributes +1 -0
  62. package/nitrogen/generated/android/Aix+autolinking.cmake +91 -0
  63. package/nitrogen/generated/android/Aix+autolinking.gradle +27 -0
  64. package/nitrogen/generated/android/AixOnLoad.cpp +70 -0
  65. package/nitrogen/generated/android/AixOnLoad.hpp +25 -0
  66. package/nitrogen/generated/android/c++/JAixAdditionalContentInsets.hpp +61 -0
  67. package/nitrogen/generated/android/c++/JAixAdditionalContentInsetsProp.hpp +63 -0
  68. package/nitrogen/generated/android/c++/JAixScrollIndicatorInsetValue.hpp +61 -0
  69. package/nitrogen/generated/android/c++/JAixScrollIndicatorInsets.hpp +63 -0
  70. package/nitrogen/generated/android/c++/JAixScrollOnFooterSizeUpdate.hpp +65 -0
  71. package/nitrogen/generated/android/c++/JHybridAixCellViewSpec.cpp +65 -0
  72. package/nitrogen/generated/android/c++/JHybridAixCellViewSpec.hpp +68 -0
  73. package/nitrogen/generated/android/c++/JHybridAixComposerSpec.cpp +48 -0
  74. package/nitrogen/generated/android/c++/JHybridAixComposerSpec.hpp +65 -0
  75. package/nitrogen/generated/android/c++/JHybridAixSpec.cpp +137 -0
  76. package/nitrogen/generated/android/c++/JHybridAixSpec.hpp +79 -0
  77. package/nitrogen/generated/android/c++/views/JHybridAixCellViewStateUpdater.cpp +60 -0
  78. package/nitrogen/generated/android/c++/views/JHybridAixCellViewStateUpdater.hpp +49 -0
  79. package/nitrogen/generated/android/c++/views/JHybridAixComposerStateUpdater.cpp +53 -0
  80. package/nitrogen/generated/android/c++/views/JHybridAixComposerStateUpdater.hpp +49 -0
  81. package/nitrogen/generated/android/c++/views/JHybridAixStateUpdater.cpp +80 -0
  82. package/nitrogen/generated/android/c++/views/JHybridAixStateUpdater.hpp +49 -0
  83. package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/AixAdditionalContentInsets.kt +41 -0
  84. package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/AixAdditionalContentInsetsProp.kt +41 -0
  85. package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/AixOnLoad.kt +35 -0
  86. package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/AixScrollIndicatorInsetValue.kt +41 -0
  87. package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/AixScrollIndicatorInsets.kt +41 -0
  88. package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/AixScrollOnFooterSizeUpdate.kt +44 -0
  89. package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/HybridAixCellViewSpec.kt +65 -0
  90. package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/HybridAixComposerSpec.kt +55 -0
  91. package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/HybridAixSpec.kt +101 -0
  92. package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/views/HybridAixCellViewManager.kt +50 -0
  93. package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/views/HybridAixCellViewStateUpdater.kt +23 -0
  94. package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/views/HybridAixComposerManager.kt +50 -0
  95. package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/views/HybridAixComposerStateUpdater.kt +23 -0
  96. package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/views/HybridAixManager.kt +50 -0
  97. package/nitrogen/generated/android/kotlin/com/margelo/nitro/aix/views/HybridAixStateUpdater.kt +23 -0
  98. package/nitrogen/generated/ios/Aix+autolinking.rb +60 -0
  99. package/nitrogen/generated/ios/Aix-Swift-Cxx-Bridge.cpp +67 -0
  100. package/nitrogen/generated/ios/Aix-Swift-Cxx-Bridge.hpp +222 -0
  101. package/nitrogen/generated/ios/Aix-Swift-Cxx-Umbrella.hpp +70 -0
  102. package/nitrogen/generated/ios/AixAutolinking.mm +49 -0
  103. package/nitrogen/generated/ios/AixAutolinking.swift +55 -0
  104. package/nitrogen/generated/ios/c++/HybridAixCellViewSpecSwift.cpp +11 -0
  105. package/nitrogen/generated/ios/c++/HybridAixCellViewSpecSwift.hpp +80 -0
  106. package/nitrogen/generated/ios/c++/HybridAixComposerSpecSwift.cpp +11 -0
  107. package/nitrogen/generated/ios/c++/HybridAixComposerSpecSwift.hpp +69 -0
  108. package/nitrogen/generated/ios/c++/HybridAixSpecSwift.cpp +11 -0
  109. package/nitrogen/generated/ios/c++/HybridAixSpecSwift.hpp +142 -0
  110. package/nitrogen/generated/ios/c++/views/HybridAixCellViewComponent.mm +101 -0
  111. package/nitrogen/generated/ios/c++/views/HybridAixComponent.mm +126 -0
  112. package/nitrogen/generated/ios/c++/views/HybridAixComposerComponent.mm +92 -0
  113. package/nitrogen/generated/ios/swift/AixAdditionalContentInsets.swift +47 -0
  114. package/nitrogen/generated/ios/swift/AixAdditionalContentInsetsProp.swift +71 -0
  115. package/nitrogen/generated/ios/swift/AixScrollIndicatorInsetValue.swift +47 -0
  116. package/nitrogen/generated/ios/swift/AixScrollIndicatorInsets.swift +71 -0
  117. package/nitrogen/generated/ios/swift/AixScrollOnFooterSizeUpdate.swift +89 -0
  118. package/nitrogen/generated/ios/swift/HybridAixCellViewSpec.swift +57 -0
  119. package/nitrogen/generated/ios/swift/HybridAixCellViewSpec_cxx.swift +151 -0
  120. package/nitrogen/generated/ios/swift/HybridAixComposerSpec.swift +56 -0
  121. package/nitrogen/generated/ios/swift/HybridAixComposerSpec_cxx.swift +131 -0
  122. package/nitrogen/generated/ios/swift/HybridAixSpec.swift +63 -0
  123. package/nitrogen/generated/ios/swift/HybridAixSpec_cxx.swift +292 -0
  124. package/nitrogen/generated/shared/c++/AixAdditionalContentInsets.hpp +79 -0
  125. package/nitrogen/generated/shared/c++/AixAdditionalContentInsetsProp.hpp +81 -0
  126. package/nitrogen/generated/shared/c++/AixScrollIndicatorInsetValue.hpp +79 -0
  127. package/nitrogen/generated/shared/c++/AixScrollIndicatorInsets.hpp +81 -0
  128. package/nitrogen/generated/shared/c++/AixScrollOnFooterSizeUpdate.hpp +83 -0
  129. package/nitrogen/generated/shared/c++/HybridAixCellViewSpec.cpp +24 -0
  130. package/nitrogen/generated/shared/c++/HybridAixCellViewSpec.hpp +65 -0
  131. package/nitrogen/generated/shared/c++/HybridAixComposerSpec.cpp +21 -0
  132. package/nitrogen/generated/shared/c++/HybridAixComposerSpec.hpp +62 -0
  133. package/nitrogen/generated/shared/c++/HybridAixSpec.cpp +36 -0
  134. package/nitrogen/generated/shared/c++/HybridAixSpec.hpp +85 -0
  135. package/nitrogen/generated/shared/c++/views/HybridAixCellViewComponent.cpp +99 -0
  136. package/nitrogen/generated/shared/c++/views/HybridAixCellViewComponent.hpp +108 -0
  137. package/nitrogen/generated/shared/c++/views/HybridAixComponent.cpp +159 -0
  138. package/nitrogen/generated/shared/c++/views/HybridAixComponent.hpp +117 -0
  139. package/nitrogen/generated/shared/c++/views/HybridAixComposerComponent.cpp +75 -0
  140. package/nitrogen/generated/shared/c++/views/HybridAixComposerComponent.hpp +106 -0
  141. package/nitrogen/generated/shared/json/AixCellViewConfig.json +11 -0
  142. package/nitrogen/generated/shared/json/AixComposerConfig.json +9 -0
  143. package/nitrogen/generated/shared/json/AixConfig.json +16 -0
  144. package/package.json +113 -14
  145. package/src/aix.tsx +43 -0
  146. package/src/fade-in/createUsePool.ts +46 -0
  147. package/src/fade-in/createUseStaggered.ts +82 -0
  148. package/src/fade-in/index.tsx +97 -0
  149. package/src/footer.tsx +30 -0
  150. package/src/index.ts +20 -16
  151. package/src/views/aix.nitro.ts +148 -0
  152. package/docs/API.md +0 -288
  153. package/jest.config.js +0 -17
  154. package/lib/__tests__/deferredIterable.test.d.ts +0 -1
  155. package/lib/__tests__/deferredIterable.test.js +0 -108
  156. package/lib/__tests__/filter.test.d.ts +0 -1
  157. package/lib/__tests__/filter.test.js +0 -53
  158. package/lib/__tests__/flatMap.test.d.ts +0 -1
  159. package/lib/__tests__/flatMap.test.js +0 -77
  160. package/lib/__tests__/lookahead.test.d.ts +0 -1
  161. package/lib/__tests__/lookahead.test.js +0 -57
  162. package/lib/__tests__/map.test.d.ts +0 -1
  163. package/lib/__tests__/map.test.js +0 -53
  164. package/lib/__tests__/merge.test.d.ts +0 -1
  165. package/lib/__tests__/merge.test.js +0 -55
  166. package/lib/__tests__/reduce.test.d.ts +0 -1
  167. package/lib/__tests__/reduce.test.js +0 -52
  168. package/lib/__tests__/spanAll.test.d.ts +0 -1
  169. package/lib/__tests__/spanAll.test.js +0 -120
  170. package/lib/concat.d.ts +0 -4
  171. package/lib/concat.js +0 -126
  172. package/lib/deferred.d.ts +0 -9
  173. package/lib/deferred.js +0 -18
  174. package/lib/deferredIterable.d.ts +0 -22
  175. package/lib/deferredIterable.js +0 -111
  176. package/lib/filter.d.ts +0 -7
  177. package/lib/filter.js +0 -99
  178. package/lib/flatMap.d.ts +0 -1
  179. package/lib/flatMap.js +0 -119
  180. package/lib/fromEvent.d.ts +0 -5
  181. package/lib/fromEvent.js +0 -13
  182. package/lib/index.d.ts +0 -16
  183. package/lib/index.js +0 -34
  184. package/lib/insert.d.ts +0 -4
  185. package/lib/insert.js +0 -113
  186. package/lib/interval.d.ts +0 -4
  187. package/lib/interval.js +0 -64
  188. package/lib/iterableToArray.d.ts +0 -1
  189. package/lib/iterableToArray.js +0 -87
  190. package/lib/iteratorToIterable.d.ts +0 -6
  191. package/lib/iteratorToIterable.js +0 -70
  192. package/lib/lookahead.d.ts +0 -10
  193. package/lib/lookahead.js +0 -78
  194. package/lib/map.d.ts +0 -4
  195. package/lib/map.js +0 -98
  196. package/lib/merge.d.ts +0 -6
  197. package/lib/merge.js +0 -21
  198. package/lib/of.d.ts +0 -3
  199. package/lib/of.js +0 -5
  200. package/lib/reduce.d.ts +0 -4
  201. package/lib/reduce.js +0 -92
  202. package/lib/restToIterable.d.ts +0 -4
  203. package/lib/restToIterable.js +0 -73
  204. package/lib/spanAll.d.ts +0 -1
  205. package/lib/spanAll.js +0 -29
  206. package/lib/tap.d.ts +0 -4
  207. package/lib/tap.js +0 -96
  208. package/lib/toCallbacks.d.ts +0 -11
  209. package/lib/toCallbacks.js +0 -97
  210. package/lib/zip.d.ts +0 -4
  211. package/lib/zip.js +0 -75
  212. package/src/__tests__/deferredIterable.test.ts +0 -22
  213. package/src/__tests__/filter.test.ts +0 -10
  214. package/src/__tests__/flatMap.test.ts +0 -12
  215. package/src/__tests__/lookahead.test.ts +0 -9
  216. package/src/__tests__/map.test.ts +0 -10
  217. package/src/__tests__/merge.test.ts +0 -9
  218. package/src/__tests__/reduce.test.ts +0 -10
  219. package/src/__tests__/spanAll.test.ts +0 -17
  220. package/src/concat.ts +0 -13
  221. package/src/deferred.ts +0 -16
  222. package/src/deferredIterable.ts +0 -111
  223. package/src/filter.ts +0 -16
  224. package/src/flatMap.ts +0 -9
  225. package/src/fromEvent.ts +0 -16
  226. package/src/insert.ts +0 -13
  227. package/src/interval.ts +0 -20
  228. package/src/iterableToArray.ts +0 -7
  229. package/src/iteratorToIterable.ts +0 -12
  230. package/src/lookahead.ts +0 -27
  231. package/src/map.ts +0 -11
  232. package/src/merge.ts +0 -18
  233. package/src/of.ts +0 -4
  234. package/src/reduce.ts +0 -12
  235. package/src/restToIterable.ts +0 -8
  236. package/src/spanAll.ts +0 -26
  237. package/src/tap.ts +0 -11
  238. package/src/toCallbacks.ts +0 -27
  239. package/src/zip.ts +0 -19
  240. package/tsconfig.json +0 -63
  241. 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
+ }