@momo-kits/native-kits 0.167.1-duo.14-debug → 0.167.1-duo.15-debug

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/CLAUDE.md CHANGED
@@ -68,7 +68,7 @@ Publishing is automated (`publish.sh` + GitLab CI on `[ci build]` / `main`). Do
68
68
 
69
69
  - `layout/ScreenLayout.kt`: `getScreenLayout()` returns window size, raw top/bottom + side insets, `contentWidth`, header geometry, the iPhone Duo `sideBar` and `duoTopInset`. `NavigationContainer` computes it once and provides `LocalScreenLayout`; reactive to fold/unfold and rotation (window size + `WindowInsets`). Metrics (`DuoMetrics`, 72pt iPhone header floor, 20pt status-bar threshold) mirror RN `Layout/utils.ts` — change both together.
70
70
  - iPhone-only: everything gates on `isPhoneIdiom()` (`platform/`), never on "iOS", so iPad windows keep the regular layout. Two detectors, as in RN: `isIPhoneDuo` (window aspect < 1.6 or asymmetric side insets) drives layout; `isIPhoneDuoDevice` (short side of screen or window > 440, RN `device.ts`) drives popups, modal and bottom sheet.
71
- - Header height is `headerHeight` (not `AppStatusBar + HEADER_HEIGHT`); the bar row is `headerBarHeight` and starts `headerStatusBarHeight` below the window top. When the status bar is off the top edge (iPhone landscape, iPhone Duo) `headerStatusBarHeight` is 0 so the title stays centered — except on the Duo outer display in portrait, where the header is 76pt and the row starts at 14pt so the title centers on the camera punchout.
71
+ - Header height is `headerHeight` (not `AppStatusBar + HEADER_HEIGHT`); the bar row is `headerBarHeight` and starts `headerStatusBarHeight` below the window top. When the status bar is off the top edge (iPhone landscape, iPhone Duo) `headerStatusBarHeight` is 0 so the title stays centered — except on the Duo outer display: in portrait the header is 76pt and the row starts at 14pt so the title centers on the camera punchout; in landscape the row centers on the camera when it sits at the top end of the strip (else on the back button) and the header reaches 8pt below the back button.
72
72
  - iPhone Duo with a side strip ≥ 60pt: back + toolkit leave the bar for `HeaderSideBar` (`navigation/component/SideBar.kt`, 48pt glass controls); custom header-right stays in the bar. `sideBar.top` is 170pt in portrait, 120pt on the inner display in landscape (height ≥ 600), 24pt on the outer display in landscape, or below the header when the status bar is on top; UIKit's `statusBarFrame` is useless on Duo. Unlike RN the column is clamped above footer (24pt gap) / rail (12pt gap).
73
73
  - Reserved regions (iOS 27.1 `reservedRegionsOfKind:`, reached through the ObjC runtime so older SDKs still build): a probe `UIView` (`platform/ReservedRegions.ios.kt` on the Compose root view; `ReservedRegionsProbe` inside SwiftUI `momoScreenLayout()`) re-reads them in `layoutSubviews`, which UIKit re-runs when they change. They push `sideBar.top` below the status cluster as it grows for calls / Live Activities, and `sideBar.bottom` (≥ 24pt) above a camera at the bottom end of the strip.
74
74
  - Tab bar on Duo is `bottomtab/DuoTabBar.kt`: a rail in the side strip (`sideBar.bottom` above the window bottom, QR below it) or, without a side bar, a floating bar spanning 6/8 grid columns. Both overlay the content; `duoFloatingTabBarInset` pads tab content, footer and FAB.
@@ -40,7 +40,7 @@ kotlin {
40
40
  }
41
41
 
42
42
  cocoapods {
43
- version = "0.167.1-duo.14-debug"
43
+ version = "0.167.1-duo.15-debug"
44
44
  summary = "IOS Shared module"
45
45
  homepage = "https://momo.vn"
46
46
  ios.deploymentTarget = "15.0"
@@ -1,6 +1,6 @@
1
1
  Pod::Spec.new do |spec|
2
2
  spec.name = 'compose'
3
- spec.version = '0.167.1-beta.4-debug'
3
+ spec.version = '0.167.1-duo.15-debug'
4
4
  spec.homepage = 'https://momo.vn'
5
5
  spec.source = { :http=> ''}
6
6
  spec.authors = ''
@@ -97,8 +97,10 @@ fun getScreenLayout(): ScreenLayout =
97
97
  internal fun calculateScreenLayout(topInset: Dp, parent: ScreenLayout? = null): ScreenLayout {
98
98
  val isPhone = isPhoneIdiom()
99
99
  if (parent != null) {
100
+ // the parent's row center reproduces its own centered geometry
101
+ val titleCenterY = parent.headerStatusBarHeight + parent.headerBarHeight / 2
100
102
  val (headerHeight, headerStatusBarHeight) =
101
- headerGeometry(topInset, isPhone, parent.width, parent.height, parent.sideBar != null)
103
+ headerGeometry(topInset, isPhone, parent.width, parent.height, parent.sideBar != null, titleCenterY)
102
104
  return parent.copy(headerHeight = headerHeight, headerStatusBarHeight = headerStatusBarHeight)
103
105
  }
104
106
 
@@ -139,32 +141,53 @@ internal fun calculateScreenLayout(topInset: Dp, parent: ScreenLayout? = null):
139
141
  internal fun ScreenLayout.consumeHorizontalInsets(): ScreenLayout =
140
142
  copy(width = contentWidth, insetLeft = 0.dp, insetRight = 0.dp, sideBar = null)
141
143
 
142
- private fun headerGeometry(topInset: Dp, isPhone: Boolean, width: Dp, height: Dp, hasSideBar: Boolean): Pair<Dp, Dp> {
144
+ // titleCenterY: window y the title row centers on for the outer display in landscape.
145
+ private fun headerGeometry(
146
+ topInset: Dp,
147
+ isPhone: Boolean,
148
+ width: Dp,
149
+ height: Dp,
150
+ hasSideBar: Boolean,
151
+ titleCenterY: Dp,
152
+ ): Pair<Dp, Dp> {
143
153
  val headerHeight = topInset + HEADER_HEIGHT.dp
144
154
  if (!isPhone) return headerHeight to topInset
145
- if (hasSideBar && topInset < STATUS_BAR_MIN_INSET && height > width && width < DuoMetrics.innerMinHeight) {
146
- // outer display: the row starts below the top so the title centers on the camera
147
- return DuoMetrics.outerHeaderHeight to DuoMetrics.outerCameraCenterY * 2 - DuoMetrics.outerHeaderHeight
155
+ if (hasSideBar && topInset < STATUS_BAR_MIN_INSET) {
156
+ if (height > width && width < DuoMetrics.innerMinHeight) {
157
+ // outer display: the row starts below the top so the title centers on the camera
158
+ return DuoMetrics.outerHeaderHeight to DuoMetrics.outerCameraCenterY * 2 - DuoMetrics.outerHeaderHeight
159
+ }
160
+ if (width > height && height < DuoMetrics.innerMinHeight) {
161
+ // reaches 8pt below a side bar control centered on the row
162
+ val centered = maxOf(IPHONE_HEADER_MIN_HEIGHT, titleCenterY + DuoMetrics.controlSize / 2 + Spacing.S)
163
+ return centered to maxOf(0.dp, titleCenterY * 2 - centered)
164
+ }
148
165
  }
149
166
  return maxOf(headerHeight, IPHONE_HEADER_MIN_HEIGHT) to if (topInset < STATUS_BAR_MIN_INSET) 0.dp else topInset
150
167
  }
151
168
 
152
- private data class StripOcclusion(val top: Dp, val bottom: Dp)
169
+ private data class StripOcclusion(val top: Dp, val topCenter: Dp, val bottom: Dp)
153
170
 
154
171
  // How far system regions reach into a side strip from its top and from its bottom end
155
- // (e.g. the camera, which sits at either end depending on the rotation).
172
+ // (e.g. the camera, which sits at either end depending on the rotation), and the center y
173
+ // of the region reaching furthest from the top.
156
174
  private fun stripOcclusion(regions: List<ReservedRegion>, stripLeft: Dp, stripRight: Dp, windowHeight: Dp): StripOcclusion {
157
175
  var top = 0.dp
176
+ var topCenter = 0.dp
158
177
  var bottom = 0.dp
159
178
  regions.forEach { region ->
160
179
  if (region.x >= stripRight || region.x + region.width <= stripLeft) return@forEach
161
- if (region.y + region.height / 2 < windowHeight / 2) {
162
- top = maxOf(top, region.y + region.height)
180
+ val centerY = region.y + region.height / 2
181
+ if (centerY < windowHeight / 2) {
182
+ if (region.y + region.height > top) {
183
+ top = region.y + region.height
184
+ topCenter = centerY
185
+ }
163
186
  } else {
164
187
  bottom = maxOf(bottom, windowHeight - region.y)
165
188
  }
166
189
  }
167
- return StripOcclusion(top, bottom)
190
+ return StripOcclusion(top, topCenter, bottom)
168
191
  }
169
192
 
170
193
  // Slab iPhones report equal side insets in landscape; iPhone Duo moves the status bar to one side.
@@ -189,9 +212,15 @@ private fun screenLayoutOf(
189
212
  val isIPhoneDuo = isPhone && isIPhoneDuoWindow(width, height, asymmetricInsets)
190
213
 
191
214
  var sideBar: SideBarLayout? = null
215
+ var titleCenterY = 0.dp
192
216
  val sideInset = maxOf(insetLeft, insetRight)
193
217
  // narrower strips cannot host a control 12pt from the inner edge
194
218
  if (isIPhoneDuo && sideInset >= DuoMetrics.controlSize + Spacing.M) {
219
+ val edge = if (insetRight >= insetLeft) SideBarEdge.RIGHT else SideBarEdge.LEFT
220
+ val stripLeft = if (edge == SideBarEdge.RIGHT) width - sideInset else 0.dp
221
+ // clear of the camera and of the status bar cluster, which grows for calls and Live
222
+ // Activities; region frames already include the system's margin
223
+ val occlusion = stripOcclusion(reservedRegions, stripLeft, stripLeft + sideInset, height)
195
224
  // UIKit reports no usable status bar frame here (a 2pt top strip), so the spot is inferred like RN
196
225
  val top = when {
197
226
  topInset >= STATUS_BAR_MIN_INSET -> topInset + HEADER_HEIGHT.dp + Spacing.M
@@ -200,11 +229,9 @@ private fun screenLayoutOf(
200
229
  // outer display in landscape: no status bar in the strip
201
230
  else -> DuoMetrics.edgeInset
202
231
  }
203
- val edge = if (insetRight >= insetLeft) SideBarEdge.RIGHT else SideBarEdge.LEFT
204
- val stripLeft = if (edge == SideBarEdge.RIGHT) width - sideInset else 0.dp
205
- // clear of the camera and of the status bar cluster, which grows for calls and Live
206
- // Activities; region frames already include the system's margin
207
- val occlusion = stripOcclusion(reservedRegions, stripLeft, stripLeft + sideInset, height)
232
+ // outer display in landscape: the title row centers on the camera when it sits at the
233
+ // top end, otherwise on the back button
234
+ titleCenterY = if (occlusion.top > top) occlusion.topCenter else top + DuoMetrics.controlSize / 2
208
235
  sideBar = SideBarLayout(
209
236
  edge = edge,
210
237
  width = sideInset,
@@ -212,7 +239,8 @@ private fun screenLayoutOf(
212
239
  bottom = maxOf(DuoMetrics.edgeInset, occlusion.bottom),
213
240
  )
214
241
  }
215
- val (headerHeight, headerStatusBarHeight) = headerGeometry(topInset, isPhone, width, height, sideBar != null)
242
+ val (headerHeight, headerStatusBarHeight) =
243
+ headerGeometry(topInset, isPhone, width, height, sideBar != null, titleCenterY)
216
244
 
217
245
  return ScreenLayout(
218
246
  width = width,
@@ -431,9 +431,10 @@ fun Footer(
431
431
 
432
432
  val backgroundColor = if (transparent) Color.Transparent else AppTheme.current.colors.background.surface
433
433
 
434
+ // inset as a whole, not padded inside, so the surface and its shadow stay off the iPhone Duo side bar
434
435
  Box(Modifier.onGloballyPositioned {
435
436
  if (footerHeightPx.intValue != it.size.height) footerHeightPx.intValue = it.size.height
436
- }) {
437
+ }.horizontalSafeAreaPadding()) {
437
438
  Box(
438
439
  Modifier
439
440
  .fillMaxWidth()
@@ -441,7 +442,6 @@ fun Footer(
441
442
  .conditional(IsShowBaseLineDebug) {
442
443
  border(1.dp, Colors.blue_03)
443
444
  }
444
- .horizontalSafeAreaPadding()
445
445
  .padding(
446
446
  top = if (useMargin) Spacing.S else 0.dp,
447
447
  start = if (useMargin) Spacing.M else 0.dp,
@@ -22,6 +22,7 @@ import vn.momo.kits.layout.getScreenLayout
22
22
  import vn.momo.kits.modifier.conditional
23
23
  import vn.momo.kits.navigation.LocalOptions
24
24
  import vn.momo.kits.navigation.LocalScrollState
25
+ import vn.momo.kits.navigation.getInputSearchType
25
26
  import vn.momo.kits.navigation.usesDuoGlassHeader
26
27
 
27
28
  enum class AnimatedHeaderRatio(val value: Float){
@@ -66,18 +67,28 @@ internal fun HeaderBackground() {
66
67
 
67
68
  // iPhone Duo glass header: the gradient leaves with the content, as the quick return header does
68
69
  val useDuoGlass = options.usesDuoGlassHeader(layout)
69
- val duoModifier = if (useDuoGlass) {
70
- val extra = if (options.headerType is HeaderType.Extended) DUO_GRADIENT_EXTRA else 0.dp
71
- Modifier.height(minHeight + extra).graphicsLayer { translationY = -scrollState.value.toFloat() }
72
- } else {
73
- Modifier.height(height)
70
+ // RN HeaderExtendHeader with search on the side bar layout: the gradient fills the header box,
71
+ // which for extended shrinks from 52pt below the header over the first 100pt of scroll
72
+ val duoSearch = layout.sideBar != null && getInputSearchType(options) != InputSearchType.None
73
+ val fillsBox = useDuoGlass || duoSearch
74
+ val duoModifier = when {
75
+ useDuoGlass -> {
76
+ val extra = if (options.headerType is HeaderType.Extended) DUO_GRADIENT_EXTRA else 0.dp
77
+ Modifier.height(minHeight + extra).graphicsLayer { translationY = -scrollState.value.toFloat() }
78
+ }
79
+ duoSearch && options.headerType is HeaderType.Extended -> {
80
+ val progress = (with(density) { scrollState.value.toDp() } / 100.dp).coerceIn(0f, 1f)
81
+ Modifier.height(minHeight + DUO_GRADIENT_EXTRA * (1 - progress))
82
+ }
83
+ duoSearch -> Modifier.height(minHeight)
84
+ else -> Modifier.height(height)
74
85
  }
75
86
 
76
87
  Box(Modifier.fillMaxWidth().then(duoModifier).clipToBounds().background(backgroundColor)) {
77
88
  // on Duo the fade spans the gradient box; a banner image keeps its 375:154 shape, clipped
78
89
  Box(Modifier
79
90
  .fillMaxWidth()
80
- .then(if (useDuoGlass && !theme.isHeaderImage()) Modifier.fillMaxHeight() else Modifier.height(maxHeight))
91
+ .then(if (fillsBox && !theme.isHeaderImage()) Modifier.fillMaxHeight() else Modifier.height(maxHeight))
81
92
  .conditional(!theme.isHeaderImage()) {
82
93
  background(Brush.verticalGradient(colors = listOf(color, backgroundColor)))
83
94
  }
@@ -10,9 +10,12 @@ import androidx.compose.ui.uikit.LocalUIViewController
10
10
  import androidx.compose.ui.unit.dp
11
11
  import kotlinx.cinterop.BetaInteropApi
12
12
  import kotlinx.cinterop.ExperimentalForeignApi
13
+ import kotlinx.cinterop.interpretObjCPointer
14
+ import kotlinx.cinterop.objcPtr
13
15
  import kotlinx.cinterop.readValue
14
16
  import kotlinx.cinterop.useContents
15
17
  import platform.CoreGraphics.CGRectZero
18
+ import platform.Foundation.NSArray
16
19
  import platform.Foundation.NSSelectorFromString
17
20
  import platform.Foundation.NSValue
18
21
  import platform.Foundation.valueForKey
@@ -21,7 +24,6 @@ import platform.UIKit.UIView
21
24
  import platform.UIKit.UIViewAutoresizingFlexibleHeight
22
25
  import platform.UIKit.UIViewAutoresizingFlexibleWidth
23
26
  import platform.darwin.NSObject
24
- import platform.darwin.NSObjectProtocol
25
27
  import platform.Foundation.NSClassFromString
26
28
 
27
29
  // The API is newer than the SDK Kotlin/Native binds, so it is reached through the runtime.
@@ -31,9 +33,15 @@ private val regionsOfKind = NSSelectorFromString("reservedRegionsOfKind:")
31
33
  @OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
32
34
  private fun UIView.occlusionRegions(target: UIView): List<ReservedRegion> {
33
35
  if (window == null || !respondsToSelector(regionsOfKind)) return emptyList()
34
- val kindClass = NSClassFromString("UIViewReservedRegionKind") as? NSObjectProtocol ?: return emptyList()
36
+ // a class object fails `as? NSObjectProtocol` (K/N checks it as an instance), so it is reinterpreted
37
+ val kindClass = NSClassFromString("UIViewReservedRegionKind")
38
+ ?.let { interpretObjCPointer<NSObject>(it.objcPtr()) } ?: return emptyList()
35
39
  val kind = kindClass.performSelector(NSSelectorFromString("occlusionRegionKind")) ?: return emptyList()
36
- val regions = performSelector(regionsOfKind, withObject = kind) as? List<*> ?: return emptyList()
40
+ val regions = when (val result = performSelector(regionsOfKind, withObject = kind)) {
41
+ is List<*> -> result
42
+ is NSArray -> List(result.count.toInt()) { result.objectAtIndex(it.toULong()) }
43
+ else -> return emptyList()
44
+ }
37
45
  return regions.mapNotNull { region ->
38
46
  val frame = ((region as? NSObject)?.valueForKey("frame") as? NSValue)?.CGRectValue ?: return@mapNotNull null
39
47
  convertRect(frame, toView = target).useContents {
package/gradle.properties CHANGED
@@ -18,7 +18,7 @@ kotlin.apple.xcodeCompatibility.nowarn=true
18
18
  name="ComposeKits"
19
19
  group=vn.momo.kits
20
20
  artifact.id=kits
21
- version=0.167.1-beta.4
21
+ version=0.167.1-duo.15
22
22
 
23
23
  repo=GitLab
24
24
  url=https://gitlab.mservice.com.vn/api/v4/projects/5400/packages/maven
@@ -110,6 +110,12 @@ extension ScreenLayout {
110
110
  let sideInset = max(insets.leading, insets.trailing)
111
111
  // narrower strips cannot host a control 12pt from the inner edge
112
112
  if sideInset >= DuoMetrics.controlSize + Spacing.M {
113
+ let edge: SideBarEdge = insets.trailing >= insets.leading ? .right : .left
114
+ let stripLeft = edge == .right ? width - sideInset : 0
115
+ // clear of the camera and of the status bar cluster, which grows for calls and Live
116
+ // Activities; region frames already include the system's margin
117
+ let occlusion = stripOcclusion(reservedRegions, stripLeft: stripLeft, stripRight: stripLeft + sideInset, windowHeight: height)
118
+
113
119
  var top = insets.top + HeaderMetrics.barHeight + Spacing.M
114
120
  if insets.top < HeaderMetrics.statusBarMinInset {
115
121
  // status bar lives in the strip: start under its cluster, as the native bar does
@@ -118,8 +124,11 @@ extension ScreenLayout {
118
124
  } else if height >= DuoMetrics.innerMinHeight {
119
125
  top = DuoMetrics.firstItemTopLandscape
120
126
  } else {
121
- // outer display in landscape: no status bar in the strip
127
+ // outer display in landscape: no status bar in the strip, the title row centers
128
+ // on the camera when it sits at the top end, otherwise on the back button
122
129
  top = DuoMetrics.edgeInset
130
+ let titleCenterY = occlusion.top > top ? occlusion.topCenter : top + DuoMetrics.controlSize / 2
131
+ (headerHeight, headerStatusBarHeight) = centeredHeaderGeometry(centerY: titleCenterY)
123
132
  }
124
133
 
125
134
  if height > width && width < DuoMetrics.innerMinHeight {
@@ -129,11 +138,6 @@ extension ScreenLayout {
129
138
  }
130
139
  }
131
140
 
132
- let edge: SideBarEdge = insets.trailing >= insets.leading ? .right : .left
133
- let stripLeft = edge == .right ? width - sideInset : 0
134
- // clear of the camera and of the status bar cluster, which grows for calls and Live
135
- // Activities; region frames already include the system's margin
136
- let occlusion = stripOcclusion(reservedRegions, stripLeft: stripLeft, stripRight: stripLeft + sideInset, windowHeight: height)
137
141
  sideBar = SideBarLayout(
138
142
  edge: edge,
139
143
  width: sideInset,
@@ -159,18 +163,29 @@ extension ScreenLayout {
159
163
  )
160
164
  }
161
165
 
166
+ /// Bar row centered on a window y, at least iPhoneMinHeight tall and reaching 8pt below
167
+ /// a side bar control centered there.
168
+ private static func centeredHeaderGeometry(centerY: CGFloat) -> (CGFloat, CGFloat) {
169
+ let height = max(HeaderMetrics.iPhoneMinHeight, centerY + DuoMetrics.controlSize / 2 + Spacing.S)
170
+ return (height, max(0, centerY * 2 - height))
171
+ }
172
+
162
173
  /// How far system regions reach into a side strip from its top and from its bottom end
163
- /// (e.g. the camera, which sits at either end depending on the rotation).
174
+ /// (e.g. the camera, which sits at either end depending on the rotation), and the center y
175
+ /// of the region reaching furthest from the top.
164
176
  private static func stripOcclusion(
165
177
  _ regions: [CGRect],
166
178
  stripLeft: CGFloat,
167
179
  stripRight: CGFloat,
168
180
  windowHeight: CGFloat
169
- ) -> (top: CGFloat, bottom: CGFloat) {
170
- regions.reduce(into: (top: CGFloat(0), bottom: CGFloat(0))) { occlusion, region in
181
+ ) -> (top: CGFloat, topCenter: CGFloat, bottom: CGFloat) {
182
+ regions.reduce(into: (top: CGFloat(0), topCenter: CGFloat(0), bottom: CGFloat(0))) { occlusion, region in
171
183
  guard region.minX < stripRight && region.maxX > stripLeft else { return }
172
184
  if region.midY < windowHeight / 2 {
173
- occlusion.top = max(occlusion.top, region.maxY)
185
+ if region.maxY > occlusion.top {
186
+ occlusion.top = region.maxY
187
+ occlusion.topCenter = region.midY
188
+ }
174
189
  } else {
175
190
  occlusion.bottom = max(occlusion.bottom, windowHeight - region.minY)
176
191
  }
@@ -204,16 +219,20 @@ public func isIPhoneDuoDevice(windowSize: CGSize? = nil) -> Bool {
204
219
  /// System occlusion regions (iOS 27.1+) in window coordinates: the iPhone Duo camera, and the
205
220
  /// status bar cluster as it grows for a call or a Live Activity. Empty before iOS 27.1.
206
221
  enum ReservedRegions {
207
- // last report of the probe, so `ScreenLayout.current` includes it
208
- static var current: [CGRect] = []
222
+ // last report of a probe, so `ScreenLayout.current` includes it
223
+ private(set) static var current: [CGRect] = []
224
+ static let didChange = Notification.Name("MoMoUIKitsReservedRegionsDidChange")
225
+
226
+ fileprivate static func publish(_ regions: [CGRect]) {
227
+ guard regions != current else { return }
228
+ current = regions
229
+ NotificationCenter.default.post(name: didChange, object: nil)
230
+ }
209
231
  }
210
232
 
211
233
  /// Invisible probe: reading the regions in layoutSubviews makes UIKit run it again when they
212
234
  /// change, so nothing polls. Port of RN `@momo-kits/reserved-regions`.
213
235
  private final class ReservedRegionsProbe: UIView {
214
- var onChange: (() -> Void)?
215
- private var last: [CGRect]?
216
-
217
236
  override init(frame: CGRect) {
218
237
  super.init(frame: frame)
219
238
  isUserInteractionEnabled = false
@@ -223,12 +242,19 @@ private final class ReservedRegionsProbe: UIView {
223
242
 
224
243
  override func layoutSubviews() {
225
244
  super.layoutSubviews()
245
+ // read even when not publishing, so UIKit keeps re-running this when they change
226
246
  let regions = occlusionRegions()
227
- guard regions != last else { return }
228
- last = regions
229
- ReservedRegions.current = regions
247
+ // every screen has a probe but the regions are window-wide: one sliding in on a push or
248
+ // a present, or off the window, would overwrite them with a partial read
249
+ guard spansWindow else { return }
230
250
  // layout pass: publish on the next turn instead of mutating SwiftUI state mid-update
231
- DispatchQueue.main.async { [weak self] in self?.onChange?() }
251
+ DispatchQueue.main.async { ReservedRegions.publish(regions) }
252
+ }
253
+
254
+ private var spansWindow: Bool {
255
+ guard let window else { return false }
256
+ let frame = convert(bounds, to: nil)
257
+ return abs(frame.minX) < 1 && abs(frame.maxX - window.bounds.width) < 1 && abs(frame.minY) < 1
232
258
  }
233
259
 
234
260
  override func didMoveToWindow() {
@@ -250,17 +276,9 @@ private final class ReservedRegionsProbe: UIView {
250
276
  }
251
277
 
252
278
  private struct ReservedRegionsProbeView: UIViewRepresentable {
253
- let onChange: () -> Void
279
+ func makeUIView(context: Context) -> ReservedRegionsProbe { ReservedRegionsProbe() }
254
280
 
255
- func makeUIView(context: Context) -> ReservedRegionsProbe {
256
- let probe = ReservedRegionsProbe()
257
- probe.onChange = onChange
258
- return probe
259
- }
260
-
261
- func updateUIView(_ probe: ReservedRegionsProbe, context: Context) {
262
- probe.onChange = onChange
263
- }
281
+ func updateUIView(_ probe: ReservedRegionsProbe, context: Context) {}
264
282
  }
265
283
 
266
284
  // MARK: - Environment
@@ -292,7 +310,11 @@ private struct ScreenLayoutReader: ViewModifier {
292
310
  .onChange(of: proxy.safeAreaInsets) { _ in refresh() }
293
311
  }
294
312
  )
295
- .background(ReservedRegionsProbeView { refresh() }.ignoresSafeArea())
313
+ .background(ReservedRegionsProbeView().ignoresSafeArea())
314
+ // any screen's probe may report, so every reader follows the shared value
315
+ .onReceive(NotificationCenter.default.publisher(for: ReservedRegions.didChange)) { _ in
316
+ refresh()
317
+ }
296
318
  .onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in
297
319
  refresh()
298
320
  }
@@ -431,13 +431,23 @@ public struct StackScreen<Content: View>: View {
431
431
  // top-aligned: an extended band runs past the bar, under the content
432
432
  .background(alignment: .top) {
433
433
  ZStack(alignment: .top) {
434
- NavigationHeaderBackground(
435
- headerType: headerType,
436
- scrollState: scrollOffset,
437
- headerTransparent: headerTransparent,
438
- fullScreenContent: fullScreenContent,
439
- backgroundColor: backgroundColor
440
- )
434
+ if layout.sideBar != nil && inputSearchType != .none && !headerTransparent {
435
+ // RN HeaderExtendHeader with search on the side bar layout: the
436
+ // gradient fades out within the header box, as without the search
437
+ DuoScrollGradient(
438
+ scrollOffset: scrollOffset,
439
+ headerHeight: layout.headerHeight,
440
+ extended: headerType == .extended
441
+ )
442
+ } else {
443
+ NavigationHeaderBackground(
444
+ headerType: headerType,
445
+ scrollState: scrollOffset,
446
+ headerTransparent: headerTransparent,
447
+ fullScreenContent: fullScreenContent,
448
+ backgroundColor: backgroundColor
449
+ )
450
+ }
441
451
  HeaderBarBackground(opacity: opacity, transparent: headerTransparent || headerType == .none)
442
452
  }
443
453
  .edgesIgnoringSafeArea([.top, .horizontal])
@@ -688,6 +698,7 @@ public struct NavigationFooter: View {
688
698
  }
689
699
 
690
700
  @Environment(\.appTheme) private var theme
701
+ @Environment(\.screenLayout) private var layout
691
702
 
692
703
  /// Read from the window, not from an enclosing `GeometryProxy`: the proxy's bottom
693
704
  /// inset grows to the keyboard height while the keyboard is up.
@@ -733,9 +744,10 @@ public struct NavigationFooter: View {
733
744
  .padding(.bottom, Self.bottomPadding - (Spacing.S - margin))
734
745
  // Compose paints the surface all the way to the bottom of the screen; the
735
746
  // footer's own frame stops at the safe area, so only the fill bleeds down.
747
+ // With side insets (iPhone Duo) it stays inside them, off the side bar.
736
748
  .background(
737
749
  (transparent ? Color.clear : theme.colors.background.surface)
738
- .ignoresSafeArea(.container, edges: [.bottom, .horizontal])
750
+ .ignoresSafeArea(.container, edges: hasSideInsets ? .bottom : [.bottom, .horizontal])
739
751
  )
740
752
  .overlay(alignment: .top) {
741
753
  if !transparent { shadow }
@@ -744,6 +756,8 @@ public struct NavigationFooter: View {
744
756
  }
745
757
  }
746
758
 
759
+ private var hasSideInsets: Bool { layout.insetLeft > 0 || layout.insetRight > 0 }
760
+
747
761
  /// Compose offsets the shadow box by -6dp, so it lands on the content above the
748
762
  /// footer rather than on the footer surface itself.
749
763
  private var shadow: some View {
@@ -1,6 +1,6 @@
1
1
  Pod::Spec.new do |s|
2
2
  s.name = 'MoMoUIKits'
3
- s.version = '0.167.1-beta.4'
3
+ s.version = '0.167.1-duo.15'
4
4
  s.summary = 'MoMoUIKits for iOS'
5
5
  s.homepage = 'https://momo.vn'
6
6
  s.license = { :type => 'MIT' }
@@ -1,6 +1,6 @@
1
1
  Pod::Spec.new do |s|
2
2
  s.name = 'MoMoUIKitsDemo'
3
- s.version = '0.167.1-beta.4'
3
+ s.version = '0.167.1-duo.15'
4
4
  s.summary = 'Demo browser for MoMoUIKits SwiftUI components'
5
5
  s.homepage = 'https://momo.vn'
6
6
  s.license = { :type => 'MIT' }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@momo-kits/native-kits",
3
- "version": "0.167.1-duo.14-debug",
3
+ "version": "0.167.1-duo.15-debug",
4
4
  "private": false,
5
5
  "dependeNavigationncies": {},
6
6
  "devDependencies": {},