@momo-kits/native-kits 0.162.44-debug → 0.162.45-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.
Files changed (199) hide show
  1. package/CLAUDE.md +45 -3
  2. package/README.md +2 -2
  3. package/compose/build.gradle.kts +9 -1
  4. package/compose/build.gradle.kts.backup +8 -0
  5. package/compose/compose.podspec +1 -1
  6. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/Platform.android.kt +2 -0
  7. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/ReservedRegions.android.kt +6 -0
  8. package/compose/src/commonMain/kotlin/vn/momo/kits/application/LiteScreen.kt +351 -111
  9. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Badge.kt +20 -21
  10. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Button.kt +2 -1
  11. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Carousel.kt +4 -2
  12. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Chip.kt +1 -1
  13. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Icon.kt +1 -1
  14. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Information.kt +1 -1
  15. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Input.kt +9 -6
  16. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputMoney.kt +11 -12
  17. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputOTP.kt +4 -9
  18. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputPhoneNumber.kt +2 -2
  19. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputSearch.kt +10 -9
  20. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputTextArea.kt +6 -6
  21. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupNotify.kt +55 -22
  22. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupPromotion.kt +12 -1
  23. package/compose/src/commonMain/kotlin/vn/momo/kits/components/ProgressInfo.kt +7 -9
  24. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Radio.kt +1 -1
  25. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Slider.kt +13 -6
  26. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Swipe.kt +13 -5
  27. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Switch.kt +3 -2
  28. package/compose/src/commonMain/kotlin/vn/momo/kits/components/TabView.kt +14 -6
  29. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tag.kt +19 -21
  30. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Text.kt +2 -2
  31. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tooltip.kt +5 -2
  32. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePickerUtils.kt +3 -1
  33. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/WheelPicker.kt +7 -15
  34. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Typography.kt +2 -1
  35. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Item.kt +1 -2
  36. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/ScreenLayout.kt +314 -0
  37. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/BottomSheet.kt +43 -4
  38. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ModalScreen.kt +8 -0
  39. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigation.kt +10 -0
  40. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/NavigationContainer.kt +4 -0
  41. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigator.kt +6 -0
  42. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackScreen.kt +197 -60
  43. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTab.kt +127 -83
  44. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTabBar.kt +54 -62
  45. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoBarCompression.kt +97 -0
  46. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoTabBar.kt +377 -0
  47. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/Header.kt +122 -24
  48. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderBackground.kt +34 -14
  49. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderJourney.kt +3 -3
  50. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderLocation.kt +1 -2
  51. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderRight.kt +116 -26
  52. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderUser.kt +1 -2
  53. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SideBar.kt +192 -0
  54. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SnackBar.kt +2 -1
  55. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/Platform.kt +3 -0
  56. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/ReservedRegions.kt +13 -0
  57. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Utils.kt +13 -0
  58. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/Platform.ios.kt +4 -0
  59. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/ReservedRegions.ios.kt +94 -0
  60. package/gradle/libs.versions.toml +1 -1
  61. package/gradle.properties +1 -1
  62. package/ios/Application/ApplicationEnvironment.swift +17 -0
  63. package/ios/Application/MaxApi.swift +30 -3
  64. package/ios/Avatar/Avatar.swift +4 -3
  65. package/ios/Badge/Badge.swift +6 -4
  66. package/ios/Badge/BadgeDot.swift +1 -1
  67. package/ios/Badge/BadgeRibbon.swift +0 -1
  68. package/ios/Button/Button.swift +15 -12
  69. package/ios/CalculatorKeyboard/CalculatorKeyboard.swift +64 -18
  70. package/ios/Carousel/Carousel.swift +29 -12
  71. package/ios/Checkbox/Checkbox.swift +7 -3
  72. package/ios/Chip/Chip.swift +5 -4
  73. package/ios/Collapse/Collapse.swift +70 -19
  74. package/ios/Colors+Radius+Spacing/Colors.swift +10 -1
  75. package/ios/Colors+Radius+Spacing/Radius.swift +6 -0
  76. package/ios/DateTimePicker/DateTimePicker.swift +54 -20
  77. package/ios/DateTimePicker/WheelPicker.swift +103 -105
  78. package/ios/Divider/Divider.swift +37 -4
  79. package/ios/Extensions/Color++.swift +38 -9
  80. package/ios/Extensions/OptionalEnvironmentObject.swift +12 -0
  81. package/ios/Extensions/SkeletonUICompat.swift +11 -0
  82. package/ios/Icon/Icon.swift +21 -23
  83. package/ios/Icon/Icons.swift +1329 -0
  84. package/ios/IconButton/IconButton.swift +15 -7
  85. package/ios/Image/Image.swift +7 -23
  86. package/ios/Information/Information.swift +27 -9
  87. package/ios/Input/ErrorView.swift +2 -2
  88. package/ios/Input/Input.swift +160 -147
  89. package/ios/Input/InputPhoneNumber.swift +12 -9
  90. package/ios/Input/InputSearch.swift +107 -77
  91. package/ios/Input/InputTextArea.swift +128 -145
  92. package/ios/InputDropDown/InputDropDown.swift +10 -7
  93. package/ios/InputMoney/InputMoney.swift +33 -11
  94. package/ios/InputOTP/InputOTP.swift +22 -12
  95. package/ios/Layout/Card.swift +50 -0
  96. package/ios/Layout/GridContext.swift +187 -3
  97. package/ios/Layout/Item.swift +5 -4
  98. package/ios/Layout/ScreenLayout.swift +385 -0
  99. package/ios/Layout/Section.swift +4 -100
  100. package/ios/Loader/Loader.swift +5 -9
  101. package/ios/Lottie/LottieView.swift +14 -2
  102. package/ios/{Application/Navigation/Overplay → Navigation}/BottomSheet.swift +169 -29
  103. package/ios/Navigation/BottomTab/BottomTab.swift +332 -0
  104. package/ios/{Application/Navigation → Navigation}/BottomTab/BottomTabBar.swift +18 -7
  105. package/ios/Navigation/BottomTab/DuoBarCompression.swift +104 -0
  106. package/ios/Navigation/BottomTab/DuoTabBar.swift +329 -0
  107. package/ios/{Application → Navigation/Component}/FloatingButton.swift +43 -54
  108. package/ios/Navigation/Component/Header.swift +545 -0
  109. package/ios/Navigation/Component/HeaderBackground.swift +154 -0
  110. package/ios/Navigation/Component/HeaderJourney.swift +73 -0
  111. package/ios/Navigation/Component/HeaderLocation.swift +48 -0
  112. package/ios/Navigation/Component/HeaderRight.swift +425 -0
  113. package/ios/Navigation/Component/HeaderTitle.swift +36 -0
  114. package/ios/{Application/Navigation → Navigation/Component}/HeaderUser.swift +21 -24
  115. package/ios/Navigation/Component/SideBar.swift +183 -0
  116. package/ios/{Application/Navigation/Overplay → Navigation/Component}/SnackBar.swift +8 -8
  117. package/ios/Navigation/HostBackGestureBridge.swift +78 -0
  118. package/ios/{Application/Navigation/Overplay → Navigation}/ModalScreen.swift +9 -2
  119. package/ios/Navigation/Navigation.swift +204 -0
  120. package/ios/Navigation/NavigationContainer.swift +195 -0
  121. package/ios/{Application/Navigation → Navigation}/Navigator.swift +207 -107
  122. package/ios/Navigation/StackScreen.swift +930 -0
  123. package/ios/Navigation/StackTransition.swift +400 -0
  124. package/ios/Navigation/SwipeBackHaptic.swift +33 -0
  125. package/ios/{Application/Navigation → Navigation}/Tracking/ScreenTracker.swift +12 -5
  126. package/ios/OTPKeyboard/OTPKeyboard.swift +8 -11
  127. package/ios/Pagination/PaginationScroll.swift +2 -2
  128. package/ios/Popup/PopupDisplay.swift +72 -135
  129. package/ios/Popup/PopupInput.swift +10 -13
  130. package/ios/Popup/PopupLayout.swift +140 -0
  131. package/ios/Popup/PopupNotify.swift +59 -69
  132. package/ios/Popup/PopupPromotion.swift +31 -47
  133. package/ios/PopupView/FullscreenPopup.swift +13 -19
  134. package/ios/PopupView/PopupView.swift +1 -1
  135. package/ios/PopupView/Utils++.swift +0 -8
  136. package/ios/ProgressInfo/ProgressInfo.swift +4 -2
  137. package/ios/Radio/Radio.swift +2 -1
  138. package/ios/Rating/Rating.swift +9 -1
  139. package/ios/Resources/dot_loading.json +558 -0
  140. package/ios/Resources/lottie_circle_loader.json +1 -0
  141. package/ios/ScrollIndicator/ScrollIndicator.swift +11 -10
  142. package/ios/Skeleton/Skeleton.swift +22 -27
  143. package/ios/Slider/Slider.swift +16 -18
  144. package/ios/Stepper/Stepper.swift +6 -1
  145. package/ios/Steps/Steps.swift +8 -6
  146. package/ios/SuggestAction/SuggestAction.swift +5 -0
  147. package/ios/Swipeable/SwipeCell.swift +10 -6
  148. package/ios/Switch/Switch.swift +22 -20
  149. package/ios/TabView/TabView.swift +102 -21
  150. package/ios/Tag/Tag.swift +15 -4
  151. package/ios/Template/Logo/Logo.swift +2 -1
  152. package/ios/Template/TrustBanner/TrustBanner.swift +106 -54
  153. package/ios/Title/Title.swift +6 -17
  154. package/ios/Tooltip/Tooltip.swift +29 -14
  155. package/ios/Typography/FontScale.swift +3 -3
  156. package/ios/Typography/Text.swift +81 -19
  157. package/ios/Uploader/Uploader.swift +3 -1
  158. package/ios/native-kits.podspec +2 -1
  159. package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
  160. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ContentView.swift +1 -1
  161. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/DuoTabDemo.swift +80 -0
  162. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/Home.swift +2 -0
  163. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/IPhoneDuoDemo.swift +173 -0
  164. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/NavigationDemo.swift +77 -1
  165. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupDisplayDemo.swift +1 -0
  166. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupNotifyDemo.swift +2 -2
  167. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupPromotionDemo.swift +2 -0
  168. package/package.json +1 -1
  169. package/scripts/gen-ios-icons.mjs +36 -0
  170. package/compose/src/commonMain/kotlin/vn/momo/kits/application/AnimationSearchInput.kt +0 -52
  171. package/compose/src/commonMain/kotlin/vn/momo/kits/application/FloatingButton.kt +0 -183
  172. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Header.kt +0 -207
  173. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderAnimated.kt +0 -39
  174. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderBackground.kt +0 -86
  175. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderDefault.kt +0 -76
  176. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderExtended.kt +0 -76
  177. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderRight.kt +0 -293
  178. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderTitle.kt +0 -33
  179. package/compose/src/commonMain/kotlin/vn/momo/kits/application/NavigationContainer.kt +0 -110
  180. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Screen.kt +0 -378
  181. package/compose/src/commonMain/kotlin/vn/momo/kits/application/useHeaderSearchAnimation.kt +0 -69
  182. package/ios/Application/Components.swift +0 -270
  183. package/ios/Application/ComposeApi.swift +0 -22
  184. package/ios/Application/HeaderRight.swift +0 -306
  185. package/ios/Application/Navigation/BottomTab/BottomTab.swift +0 -104
  186. package/ios/Application/Navigation/HeaderBackProps.swift +0 -66
  187. package/ios/Application/Navigation/HeaderTitle.swift +0 -57
  188. package/ios/Application/Navigation/NavigationContainer.swift +0 -213
  189. package/ios/Application/Navigation/NavigationOptions.swift +0 -109
  190. package/ios/Application/Navigation/NavigatorManager.swift +0 -22
  191. package/ios/Application/Navigation/component/Header.swift +0 -178
  192. package/ios/Application/Navigation/component/HeaderBackground.swift +0 -83
  193. package/ios/Application/Navigation/component/HeaderColor.swift +0 -65
  194. package/ios/Application/Navigation/component/HeaderRightWidthKey.swift +0 -11
  195. package/ios/Application/Navigation/component/NavigationFooter.swift +0 -90
  196. package/ios/Application/Navigation/component/NavigationHeaderRight.swift +0 -196
  197. package/ios/Application/Screen.swift +0 -250
  198. package/ios/Application/StackScreen.swift +0 -492
  199. /package/ios/{Application/Navigation → Navigation}/BottomTab/CurvedContainer.swift +0 -0
@@ -1,8 +1,8 @@
1
1
  import SwiftUI
2
2
 
3
- /// 12-column section container: horizontal margin, optional background image, and a
4
- /// wrapping row of children that read their span size from `\.gridContext`.
5
- /// Mirrors `vn.momo.kits.layout.Section`.
3
+ /// Grid section container (12 columns, 24 when the content is wider than 600pt): horizontal
4
+ /// margin, optional background image, and a wrapping row of children that read their span
5
+ /// size from `\.gridContext`. Mirrors `vn.momo.kits.layout.Section`.
6
6
  public struct SectionView<Content: View>: View {
7
7
  // MARK: Lifecycle
8
8
 
@@ -19,116 +19,20 @@ public struct SectionView<Content: View>: View {
19
19
  // MARK: Public
20
20
 
21
21
  public var body: some View {
22
- flow
23
- .environment(\.gridContext, grid)
24
- .frame(maxWidth: .infinity, alignment: .topLeading)
25
- .background(widthReader)
22
+ GridFlow(gutter: Spacing.M, content: content)
26
23
  .background(background)
27
24
  .padding(.horizontal, useMargin ? Spacing.M : 0)
28
25
  }
29
26
 
30
27
  // MARK: Private
31
28
 
32
- private static var columns: Int { 12 }
33
- private static var gutter: CGFloat { Spacing.M }
34
-
35
29
  private let useMargin: Bool
36
30
  private let backgroundImage: String?
37
31
  private let content: () -> Content
38
32
 
39
- /// Width available *inside* the horizontal margin — the Compose `BoxWithConstraints` equivalent.
40
- @State private var availableWidth: CGFloat = 0
41
-
42
- private var grid: GridContext {
43
- let columns = CGFloat(Self.columns)
44
- // The trailing `- 1 / columns` mirrors Compose: it absorbs rounding so 12 spans
45
- // plus their gutters never overflow the row by a sub-point.
46
- let sizePerSpan = (availableWidth - Self.gutter * (columns - 1)) / columns - 1 / columns
47
- return GridContext(numberOfColumns: Self.columns, gutter: Self.gutter, sizePerSpan: max(0, sizePerSpan))
48
- }
49
-
50
- @ViewBuilder private var flow: some View {
51
- if #available(iOS 16.0, *) {
52
- FlowRowLayout(gutter: Self.gutter) { content() }
53
- } else {
54
- // No `Layout` protocol before iOS 16: children stack instead of wrapping.
55
- // Full-width (span 12) items — the common case — look identical.
56
- VStack(alignment: .leading, spacing: Self.gutter) { content() }
57
- }
58
- }
59
-
60
33
  @ViewBuilder private var background: some View {
61
34
  if let backgroundImage {
62
35
  ImageView(backgroundImage, contentMode: .fill).clipped()
63
36
  }
64
37
  }
65
-
66
- private var widthReader: some View {
67
- GeometryReader { proxy in
68
- Color.clear.preference(key: SectionWidthKey.self, value: proxy.size.width)
69
- }
70
- .onPreferenceChange(SectionWidthKey.self) { availableWidth = $0 }
71
- }
72
- }
73
-
74
- private struct SectionWidthKey: PreferenceKey {
75
- static var defaultValue: CGFloat = 0
76
-
77
- static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
78
- value = max(value, nextValue())
79
- }
80
- }
81
-
82
- /// Wrapping row with equal horizontal/vertical spacing — the Compose `FlowRow` equivalent.
83
- @available(iOS 16.0, *)
84
- private struct FlowRowLayout: Layout {
85
- struct Row {
86
- var items: [(index: Int, size: CGSize)] = []
87
- var width: CGFloat = 0
88
- var height: CGFloat = 0
89
- }
90
-
91
- let gutter: CGFloat
92
-
93
- func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize {
94
- let maxWidth = proposal.width ?? .infinity
95
- let rows = rows(for: subviews, maxWidth: maxWidth)
96
- let height = rows.reduce(0) { $0 + $1.height } + gutter * CGFloat(max(0, rows.count - 1))
97
- return CGSize(width: proposal.width ?? rows.map(\.width).max() ?? 0, height: height)
98
- }
99
-
100
- func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) {
101
- var y = bounds.minY
102
- for row in rows(for: subviews, maxWidth: bounds.width) {
103
- var x = bounds.minX
104
- for item in row.items {
105
- subviews[item.index].place(
106
- at: CGPoint(x: x, y: y),
107
- anchor: .topLeading,
108
- proposal: ProposedViewSize(item.size)
109
- )
110
- x += item.size.width + gutter
111
- }
112
- y += row.height + gutter
113
- }
114
- }
115
-
116
- private func rows(for subviews: Subviews, maxWidth: CGFloat) -> [Row] {
117
- var rows: [Row] = []
118
- var current = Row()
119
- for index in subviews.indices {
120
- let size = subviews[index].sizeThatFits(ProposedViewSize(width: maxWidth, height: nil))
121
- let widthIfAppended = current.items.isEmpty ? size.width : current.width + gutter + size.width
122
- // Sub-point tolerance: spans are computed to land exactly on `maxWidth`.
123
- if !current.items.isEmpty, widthIfAppended > maxWidth + 0.5 {
124
- rows.append(current)
125
- current = Row()
126
- }
127
- current.width = current.items.isEmpty ? size.width : current.width + gutter + size.width
128
- current.height = max(current.height, size.height)
129
- current.items.append((index, size))
130
- }
131
- if !current.items.isEmpty { rows.append(current) }
132
- return rows
133
- }
134
38
  }
@@ -12,8 +12,7 @@ public enum LoaderType {
12
12
  /// Mirrors Compose `Loader(type:, tintColor:, modifier:)` which delegates to
13
13
  /// `DotLoader` / `SpinnerLoader`. Both variants render Lottie animations
14
14
  /// (`files/dot_loading` and `files/lottie_circle_loader`) and optionally tint
15
- /// the fill/stroke. The tint is reproduced here by masking a solid color rect
16
- /// with the (monochrome) Lottie animation.
15
+ /// the fill/stroke through a Lottie color value provider.
17
16
  public struct Loader: View {
18
17
  private let type: LoaderType
19
18
  private let tintColor: Color?
@@ -97,17 +96,14 @@ public struct ProgressBar: View {
97
96
  }
98
97
  }
99
98
 
100
- /// Renders a looping Lottie animation, optionally recolored via a mask.
99
+ // Tinted inside Lottie, as Compose does: SwiftUI masks/color effects over a UIKit-backed view aren't reliable.
101
100
  private struct TintedLottie: View {
102
101
  let name: String
103
102
  let tintColor: Color?
104
103
 
105
104
  var body: some View {
106
- if let tintColor = tintColor {
107
- tintColor
108
- .mask(LottieView(name: name, loopMode: .loop))
109
- } else {
110
- LottieView(name: name, loopMode: .loop)
111
- }
105
+ LottieView(name: name, loopMode: .loop, tintColor: tintColor.map { UIColor($0) })
106
+ // The tint is applied once in makeUIView; a new tint needs a new view.
107
+ .id(tintColor)
112
108
  }
113
109
  }
@@ -15,6 +15,8 @@ struct LottieView: UIViewRepresentable {
15
15
  var loopMode: LottieLoopMode = .playOnce
16
16
  var contentMode : UIView.ContentMode = .scaleAspectFit
17
17
  var onDone: (() -> Void)?
18
+ // Recolors every fill and stroke, as Compose's iOS LottieAnimation does; nil keeps the file's colors.
19
+ var tintColor: UIColor?
18
20
 
19
21
  // Add static cache for animations
20
22
  private static var animationCache = NSCache<NSString, LottieAnimation>()
@@ -36,7 +38,8 @@ struct LottieView: UIViewRepresentable {
36
38
  animationView.animation = cachedAnimation
37
39
  setupAnimationView(animationView, view)
38
40
  } else {
39
- LottieAnimation.loadedFrom(url: URL(string: url)!, closure: { animation in
41
+ guard let animationURL = URL(string: url) else { return view }
42
+ LottieAnimation.loadedFrom(url: animationURL, closure: { animation in
40
43
  if let animation = animation {
41
44
  Self.animationCache.setObject(animation, forKey: url as NSString)
42
45
  animationView.animation = animation
@@ -49,7 +52,8 @@ struct LottieView: UIViewRepresentable {
49
52
  if let cachedAnimation = Self.animationCache.object(forKey: name as NSString) {
50
53
  animationView.animation = cachedAnimation
51
54
  setupAnimationView(animationView, view)
52
- } else if let animation = LottieAnimation.named(name) {
55
+ } else if let animation = LottieAnimation.named(name, bundle: MoMoUIKitsResources.bundle)
56
+ ?? LottieAnimation.named(name) {
53
57
  Self.animationCache.setObject(animation, forKey: name as NSString)
54
58
  animationView.animation = animation
55
59
  setupAnimationView(animationView, view)
@@ -62,6 +66,12 @@ struct LottieView: UIViewRepresentable {
62
66
  private func setupAnimationView(_ animationView: LottieAnimationView, _ containerView: UIView) {
63
67
  animationView.contentMode = contentMode
64
68
  animationView.loopMode = loopMode
69
+ if let tintColor {
70
+ animationView.setValueProvider(
71
+ ColorValueProvider(tintColor.lottieColorValue),
72
+ keypath: AnimationKeypath(keypath: "**.Color")
73
+ )
74
+ }
65
75
  animationView.backgroundBehavior = loopMode == .loop ? .pauseAndRestore : .pause
66
76
 
67
77
  // Optimize rendering
@@ -71,6 +81,8 @@ struct LottieView: UIViewRepresentable {
71
81
  containerView.addSubview(animationView)
72
82
 
73
83
  NSLayoutConstraint.activate([
84
+ animationView.centerXAnchor.constraint(equalTo: containerView.centerXAnchor),
85
+ animationView.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
74
86
  animationView.widthAnchor.constraint(equalTo: containerView.widthAnchor),
75
87
  animationView.heightAnchor.constraint(equalTo: containerView.heightAnchor)
76
88
  ])
@@ -2,7 +2,7 @@
2
2
  // BottomSheet.swift
3
3
  // MoMoUIKits
4
4
  //
5
- // SwiftUI mirror of Compose's `BottomSheet` (navigation/BottomSheet.kt).
5
+ // SwiftUI mirror of Compose's `navigation/BottomSheet.kt`.
6
6
  // A sheet that slides up from the bottom over a dimmed scrim, with a drag
7
7
  // handle, a header (title or custom), a divider and the content body.
8
8
  // Dragging down past a threshold (or tapping the scrim) dismisses it.
@@ -35,16 +35,21 @@ public struct BottomSheetHeader {
35
35
 
36
36
  // MARK: - Bottom sheet view
37
37
 
38
- @available(iOS 16.0, *)
39
38
  struct BottomSheetView<Content: View>: View {
39
+ let overplayId: UUID
40
40
  let header: BottomSheetHeader
41
41
  let isSurface: Bool
42
42
  let barrierDismissible: Bool
43
+ var draggable: Bool = true
44
+ /// Keeps the content above the home indicator (Compose `AppNavigationBar` padding).
45
+ var useBottomInset: Bool = true
46
+ var footer: (() -> AnyView)? = nil
43
47
  let content: () -> Content
44
48
 
45
49
  @EnvironmentObject private var navigator: Navigator
46
50
  @Environment(\.appTheme) private var theme
47
51
  @Environment(\.applicationEnvironment) private var appEnv
52
+ @Environment(\.screenLayout) private var layout
48
53
 
49
54
  /// Vertical translation of the sheet; starts off-screen and animates to 0.
50
55
  @State private var sheetOffset: CGFloat = UIScreen.main.bounds.height
@@ -52,6 +57,7 @@ struct BottomSheetView<Content: View>: View {
52
57
  @State private var backgroundAlpha: CGFloat = 0
53
58
  @State private var sheetHeight: CGFloat = 0
54
59
  @State private var didAppear = false
60
+ @State private var isClosing = false
55
61
  @State private var keyboardHeight: CGFloat = 0
56
62
  /// Live drag offset, isolated from `sheetOffset` so a touch-move only invalidates
57
63
  /// this gesture-scoped value instead of forcing the whole body (including
@@ -66,8 +72,35 @@ struct BottomSheetView<Content: View>: View {
66
72
  /// steady-state (fully open) scrim peaks at 0.6, matching ModalScreen's peak.
67
73
  private let maxDynamicAlpha: CGFloat = 0.3
68
74
 
69
- private var screenHeight: CGFloat { UIScreen.main.bounds.height }
70
- private var bottomInset: CGFloat { safeAreaBottomInset() }
75
+ // UIScreen stays on the outer display on iPhone Duo, even when unfolded
76
+ private var screenHeight: CGFloat { isDuo ? layout.height : UIScreen.main.bounds.height }
77
+
78
+ private var isDuo: Bool { layout.isDuoDevice }
79
+ // iPhone Duo inner display: center the sheet instead of spanning the width
80
+ private var isDuoCentered: Bool { isDuo && layout.width >= 600 }
81
+
82
+ /// Keeps the portrait width in landscape, like system sheets.
83
+ private var duoCenteredWidth: CGFloat {
84
+ min(
85
+ min(layout.width, layout.height) - Spacing.L * 2,
86
+ layout.width - max(layout.insets.leading, layout.insets.trailing, Spacing.L) * 2
87
+ )
88
+ }
89
+
90
+ /// Landscape side buttons span the full height, so the content is inset; in portrait the
91
+ /// side status bar only covers the top corner.
92
+ private var sideInsetLeading: CGFloat {
93
+ isDuo && !isDuoCentered && layout.width > layout.height ? layout.insets.leading : 0
94
+ }
95
+
96
+ private var sideInsetTrailing: CGFloat {
97
+ isDuo && !isDuoCentered && layout.width > layout.height ? layout.insets.trailing : 0
98
+ }
99
+
100
+ private var maxHeight: CGFloat {
101
+ isDuo ? layout.height - max(layout.duoTopInset, Spacing.XL) : screenHeight - 90
102
+ }
103
+ private var bottomInset: CGFloat { useBottomInset ? safeAreaBottomInset() : 0 }
71
104
  /// While the keyboard is up, it already covers the home-indicator area, so it
72
105
  /// replaces (rather than adds to) the usual bottom-inset filler.
73
106
  private var effectiveBottomInset: CGFloat { keyboardHeight > 0 ? keyboardHeight : bottomInset }
@@ -110,7 +143,7 @@ struct BottomSheetView<Content: View>: View {
110
143
  .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillChangeFrameNotification)) { notification in
111
144
  guard let endFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return }
112
145
  withAnimation(.easeOut(duration: 0.25)) {
113
- keyboardHeight = UIScreen.main.bounds.height - endFrame.origin.y
146
+ keyboardHeight = screenHeight - endFrame.origin.y
114
147
  }
115
148
  }
116
149
  .onAppear {
@@ -128,33 +161,48 @@ struct BottomSheetView<Content: View>: View {
128
161
 
129
162
  private var sheet: some View {
130
163
  VStack(spacing: 0) {
131
- // Drag handle + header (the draggable region).
132
164
  VStack(spacing: 0) {
133
- Spacer().frame(height: 8)
134
- Capsule()
135
- .fill(Colors.black06)
136
- .frame(width: 40, height: 4)
137
- Spacer().frame(height: 4)
138
- headerView
139
- .frame(height: 56)
140
- .padding(.horizontal, Spacing.M)
141
- }
142
- .frame(maxWidth: .infinity)
143
- .frame(height: 72)
144
- .contentShape(Rectangle())
145
- .gesture(dragGesture)
165
+ // Drag handle + header (the draggable region).
166
+ VStack(spacing: 0) {
167
+ Spacer().frame(height: 8)
168
+ Capsule()
169
+ .fill(Colors.black06)
170
+ .frame(width: 40, height: 4)
171
+ Spacer().frame(height: 4)
172
+ headerView
173
+ .frame(height: 56)
174
+ .padding(.horizontal, Spacing.M)
175
+ .padding(.leading, sideInsetLeading)
176
+ .padding(.trailing, sideInsetTrailing)
177
+ }
178
+ .frame(maxWidth: .infinity)
179
+ .frame(height: 72)
180
+ .contentShape(Rectangle())
181
+ .gesture(dragGesture, including: draggable ? .all : .subviews)
146
182
 
147
- Rectangle()
148
- .fill(theme.colors.border.default)
149
- .frame(height: 1)
183
+ Rectangle()
184
+ .fill(theme.colors.border.default)
185
+ .frame(height: 1)
150
186
 
151
- content()
187
+ content()
188
+ .padding(.leading, sideInsetLeading)
189
+ .padding(.trailing, sideInsetTrailing)
190
+ // a tall sheet keeps its header (and footer) and gives the content what is left
191
+ .layoutPriority(-1)
192
+
193
+ if let footer {
194
+ BottomSheetFooter(content: footer)
195
+ }
196
+ }
197
+ // The filler stays outside the cap, which shrinks by it instead: the sheet still ends at
198
+ // `maxHeight`, and the iOS 15 fallback never measures the keyboard as content.
199
+ .modifier(CapHeight(maxHeight: maxHeight - effectiveBottomInset))
152
200
 
153
- backgroundColor
201
+ (footer != nil ? theme.colors.background.surface : backgroundColor)
154
202
  .frame(height: effectiveBottomInset)
155
203
  }
156
- .frame(maxWidth: .infinity)
157
- .frame(alignment: .bottom)
204
+ .frame(maxWidth: isDuoCentered ? duoCenteredWidth : .infinity)
205
+ .clipped()
158
206
  .background(
159
207
  GeometryReader { geo in
160
208
  Color.clear.onAppear { sheetHeight = geo.size.height }
@@ -198,14 +246,16 @@ struct BottomSheetView<Content: View>: View {
198
246
  // same bottom-anchored top edge — is still in flight doesn't fight a
199
247
  // moving target. Without this, the live drag value chases an animated
200
248
  // one, producing a couple of corrective jumps before it settles.
201
- if sheetOffset != 0 {
249
+ // A closing sheet keeps going down.
250
+ if sheetOffset != 0 && !isClosing {
202
251
  sheetOffset = 0
203
252
  }
204
253
  }
205
254
  .updating($dragTranslation) { value, state, _ in
206
- state = max(value.translation.height, 0)
255
+ state = isClosing ? 0 : max(value.translation.height, 0)
207
256
  }
208
257
  .onEnded { value in
258
+ guard !isClosing else { return }
209
259
  let finalOffset = max(value.translation.height, 0)
210
260
  // Capture into persisted state BEFORE @GestureState resets to 0,
211
261
  // avoiding a one-frame snap back to rest before close()'s own
@@ -233,6 +283,8 @@ struct BottomSheetView<Content: View>: View {
233
283
  /// Mirrors Compose's `closeEvent`: slide out over 200ms, then fade the scrim
234
284
  /// out over 100ms only after the slide finishes, then dismiss.
235
285
  private func close() {
286
+ guard !isClosing else { return }
287
+ isClosing = true
236
288
  withAnimation(.timingCurve(0.3, 0, 0.8, 0.15, duration: 0.2)) {
237
289
  sheetOffset = max(sheetHeight, screenHeight)
238
290
  }
@@ -242,12 +294,100 @@ struct BottomSheetView<Content: View>: View {
242
294
  }
243
295
  }
244
296
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
245
- navigator.finishOverplayDismiss()
297
+ navigator.finishOverplayDismiss(id: overplayId)
246
298
  }
247
299
  }
248
300
  }
249
301
 
302
+ /// Compose's `heightIn(max:)`. `.frame(maxHeight:)` grows to the cap whenever the proposal is
303
+ /// larger, leaving a gap above the header; this sizes the sheet to its content and only caps it
304
+ /// when taller.
305
+ private struct CapHeight: ViewModifier {
306
+ let maxHeight: CGFloat
307
+
308
+ func body(content: Content) -> some View {
309
+ if #available(iOS 16.0, *) {
310
+ CapHeightLayout(maxHeight: maxHeight) { content }
311
+ } else {
312
+ content.modifier(CapHeightFallback(maxHeight: maxHeight))
313
+ }
314
+ }
315
+ }
316
+
317
+ /// iOS 15 has no `Layout`: the sheet hugs its ideal height until that outgrows the cap, then is
318
+ /// held at the cap. A capped sheet can't read its ideal height (that needs a second copy of the
319
+ /// content), so it is re-measured only when the cap grows, e.g. once the keyboard goes down.
320
+ private struct CapHeightFallback: ViewModifier {
321
+ let maxHeight: CGFloat
322
+ @State private var capped = false
323
+ @State private var idealHeight: CGFloat = 0
324
+
325
+ func body(content: Content) -> some View {
326
+ content
327
+ // both modifiers stay in place whatever `capped` is, so the content keeps its identity
328
+ .fixedSize(horizontal: false, vertical: !capped)
329
+ .background(
330
+ GeometryReader { proxy in
331
+ Color.clear
332
+ .onAppear { measure(proxy.size.height) }
333
+ .onChange(of: proxy.size.height) { measure($0) }
334
+ }
335
+ )
336
+ .frame(height: capped ? maxHeight : nil, alignment: .top)
337
+ .onChange(of: maxHeight) { cap in
338
+ if capped {
339
+ // the ideal height was over the old cap, so only a taller cap can release it
340
+ if cap > idealHeight { capped = false }
341
+ } else if idealHeight > cap {
342
+ capped = true
343
+ }
344
+ }
345
+ }
346
+
347
+ private func measure(_ height: CGFloat) {
348
+ guard !capped else { return }
349
+ idealHeight = height
350
+ if height > maxHeight { capped = true }
351
+ }
352
+ }
353
+
250
354
  @available(iOS 16.0, *)
355
+ private struct CapHeightLayout: Layout {
356
+ let maxHeight: CGFloat
357
+
358
+ func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
359
+ guard let subview = subviews.first else { return .zero }
360
+ let cap = min(maxHeight, proposal.height ?? .infinity)
361
+ // ideal first: a scroll view would otherwise fill whatever height it is offered
362
+ let ideal = subview.sizeThatFits(ProposedViewSize(width: proposal.width, height: nil))
363
+ return subview.sizeThatFits(ProposedViewSize(width: proposal.width, height: min(ideal.height, cap)))
364
+ }
365
+
366
+ func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
367
+ subviews.first?.place(at: bounds.origin, proposal: ProposedViewSize(bounds.size))
368
+ }
369
+ }
370
+
371
+ /// Mirrors Compose's `BottomSheetFooter`: a surface band with a soft shadow cast on the content.
372
+ private struct BottomSheetFooter: View {
373
+ let content: () -> AnyView
374
+
375
+ @Environment(\.appTheme) private var theme
376
+
377
+ var body: some View {
378
+ content()
379
+ .frame(maxWidth: .infinity)
380
+ .padding(.vertical, Spacing.S)
381
+ .background(theme.colors.background.surface)
382
+ .overlay(alignment: .top) {
383
+ LinearGradient(colors: [.clear, Color.black.opacity(0.05)], startPoint: .top, endPoint: .bottom)
384
+ .frame(height: 6)
385
+ .offset(y: -6)
386
+ .allowsHitTesting(false)
387
+ }
388
+ }
389
+ }
390
+
251
391
  private func safeAreaBottomInset() -> CGFloat {
252
392
  let bottomInset = UIApplication.shared.connectedScenes
253
393
  .compactMap { ($0 as? UIWindowScene)?.keyWindow?.safeAreaInsets.bottom }