@momo-kits/native-kits 0.162.44 → 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.
- package/CLAUDE.md +45 -3
- package/README.md +2 -2
- package/build.gradle.kts +11 -0
- package/compose/build.gradle.kts +198 -0
- package/compose/build.gradle.kts.backup +198 -0
- package/compose/compose.podspec +47 -0
- package/compose/src/androidMain/kotlin/vn/momo/kits/const/HostFont.android.kt +19 -0
- package/compose/src/androidMain/kotlin/vn/momo/kits/navigation/ScrollToTop.android.kt +6 -0
- package/compose/src/androidMain/kotlin/vn/momo/kits/platform/BackGesture.android.kt +12 -0
- package/compose/src/androidMain/kotlin/vn/momo/kits/platform/OsFontScale.android.kt +7 -0
- package/compose/src/androidMain/kotlin/vn/momo/kits/platform/Platform.android.kt +153 -0
- package/compose/src/androidMain/kotlin/vn/momo/kits/platform/ReservedRegions.android.kt +6 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/Context.kt +124 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/LiteScreen.kt +1071 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/Localize.kt +269 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/WidgetContainer.kt +56 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Avatar.kt +157 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Badge.kt +81 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/BadgeDot.kt +32 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/BadgeRibbon.kt +338 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/BaselineView.kt +175 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Button.kt +357 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Carousel.kt +115 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/CheckBox.kt +89 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Chip.kt +128 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Collapse.kt +209 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/CupertinoOverscroll.kt +542 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Divider.kt +49 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Icon.kt +86 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/IconButton.kt +125 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Image.kt +231 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Information.kt +189 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Input.kt +478 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputDropDown.kt +114 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputMoney.kt +265 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputOTP.kt +235 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputPhoneNumber.kt +223 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputSearch.kt +265 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputTextArea.kt +185 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/LazyColumnWithBouncing.kt +343 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Loader.kt +108 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationDot.kt +56 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationNumber.kt +41 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationScroll.kt +84 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationWhiteDot.kt +40 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupNotify.kt +361 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupPromotion.kt +108 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/ProgressInfo.kt +308 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Radio.kt +59 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Rating.kt +87 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/ScaleSizeScope.kt +21 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Skeleton.kt +90 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Slider.kt +330 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Stepper.kt +220 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Steps.kt +473 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/SuggestAction.kt +122 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Swipe.kt +207 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Switch.kt +92 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/TabView.kt +560 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tag.kt +86 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Text.kt +125 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Title.kt +207 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tooltip.kt +501 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/TrustBanner.kt +176 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/Uploader.kt +182 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePicker.kt +193 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePickerTypes.kt +29 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePickerUtils.kt +236 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/WheelPicker.kt +180 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/const/Colors.kt +325 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/const/HostFont.kt +16 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/const/KitDesignSystem.kt +18 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/const/KitFontScale.kt +22 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/const/Radius.kt +12 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/const/Spacing.kt +16 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/const/Theme.kt +194 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/const/Typography.kt +283 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Card.kt +54 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Item.kt +50 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/layout/ScreenLayout.kt +314 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Section.kt +41 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/AutomationId.kt +52 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Clickable.kt +66 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Conditional.kt +11 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/DeprecatedModifier.kt +104 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Shadow.kt +48 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Size.kt +51 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/BottomSheet.kt +338 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/HostBackGestureBridge.kt +58 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ModalScreen.kt +137 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigation.kt +116 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/NavigationContainer.kt +205 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigator.kt +399 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ScrollToTop.kt +8 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackScreen.kt +732 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackTransition.kt +148 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/SwipeBackHaptic.kt +35 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTab.kt +228 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTabBar.kt +217 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/CurvedContainer.kt +86 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoBarCompression.kt +97 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoTabBar.kt +377 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/FloatingButton.kt +169 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/Header.kt +455 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderBackground.kt +103 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderJourney.kt +106 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderLocation.kt +74 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderRight.kt +399 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderTitle.kt +39 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderUser.kt +363 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SideBar.kt +192 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SnackBar.kt +133 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/tracking/ScreenTracker.kt +127 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/platform/BackGesture.kt +12 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/platform/ComposeLottieAnimation.kt +91 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/platform/OsFontScale.kt +9 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/platform/Platform.kt +74 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/platform/ReservedRegions.kt +13 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Icons.kt +1329 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Resources.kt +55 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Tracking.kt +15 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Utils.kt +118 -0
- package/compose/src/iosMain/kotlin/vn/momo/kits/const/HostFont.ios.kt +40 -0
- package/compose/src/iosMain/kotlin/vn/momo/kits/navigation/HostBackGesture.ios.kt +45 -0
- package/compose/src/iosMain/kotlin/vn/momo/kits/navigation/ScrollToTop.ios.kt +32 -0
- package/compose/src/iosMain/kotlin/vn/momo/kits/platform/BackGesture.ios.kt +38 -0
- package/compose/src/iosMain/kotlin/vn/momo/kits/platform/OsFontScale.ios.kt +66 -0
- package/compose/src/iosMain/kotlin/vn/momo/kits/platform/Platform.ios.kt +217 -0
- package/compose/src/iosMain/kotlin/vn/momo/kits/platform/ReservedRegions.ios.kt +94 -0
- package/gradle/libs.versions.toml +69 -0
- package/gradle/wrapper/gradle-wrapper.jar +0 -0
- package/gradle/wrapper/gradle-wrapper.properties +8 -0
- package/gradle.properties +26 -0
- package/gradlew +252 -0
- package/gradlew.bat +94 -0
- package/ios/Application/ApplicationEnvironment.swift +17 -0
- package/ios/Application/MaxApi.swift +30 -3
- package/ios/Avatar/Avatar.swift +4 -3
- package/ios/Badge/Badge.swift +6 -4
- package/ios/Badge/BadgeDot.swift +1 -1
- package/ios/Badge/BadgeRibbon.swift +0 -1
- package/ios/Button/Button.swift +15 -12
- package/ios/CalculatorKeyboard/CalculatorKeyboard.swift +64 -18
- package/ios/Carousel/Carousel.swift +29 -12
- package/ios/Checkbox/Checkbox.swift +7 -3
- package/ios/Chip/Chip.swift +5 -4
- package/ios/Collapse/Collapse.swift +70 -19
- package/ios/Colors+Radius+Spacing/Colors.swift +10 -1
- package/ios/Colors+Radius+Spacing/Radius.swift +6 -0
- package/ios/DateTimePicker/DateTimePicker.swift +54 -20
- package/ios/DateTimePicker/WheelPicker.swift +103 -105
- package/ios/Divider/Divider.swift +37 -4
- package/ios/Extensions/Color++.swift +38 -9
- package/ios/Extensions/OptionalEnvironmentObject.swift +12 -0
- package/ios/Extensions/SkeletonUICompat.swift +11 -0
- package/ios/Icon/Icon.swift +21 -23
- package/ios/Icon/Icons.swift +1329 -0
- package/ios/IconButton/IconButton.swift +15 -7
- package/ios/Image/Image.swift +7 -23
- package/ios/Information/Information.swift +27 -9
- package/ios/Input/ErrorView.swift +2 -2
- package/ios/Input/Input.swift +160 -147
- package/ios/Input/InputPhoneNumber.swift +12 -9
- package/ios/Input/InputSearch.swift +107 -77
- package/ios/Input/InputTextArea.swift +128 -145
- package/ios/InputDropDown/InputDropDown.swift +10 -7
- package/ios/InputMoney/InputMoney.swift +33 -11
- package/ios/InputOTP/InputOTP.swift +22 -12
- package/ios/Layout/Card.swift +50 -0
- package/ios/Layout/GridContext.swift +187 -3
- package/ios/Layout/Item.swift +5 -4
- package/ios/Layout/ScreenLayout.swift +385 -0
- package/ios/Layout/Section.swift +4 -100
- package/ios/Loader/Loader.swift +5 -9
- package/ios/Lottie/LottieView.swift +14 -2
- package/ios/{Application/Navigation/Overplay → Navigation}/BottomSheet.swift +169 -29
- package/ios/Navigation/BottomTab/BottomTab.swift +332 -0
- package/ios/{Application/Navigation → Navigation}/BottomTab/BottomTabBar.swift +18 -7
- package/ios/Navigation/BottomTab/DuoBarCompression.swift +104 -0
- package/ios/Navigation/BottomTab/DuoTabBar.swift +329 -0
- package/ios/{Application → Navigation/Component}/FloatingButton.swift +43 -54
- package/ios/Navigation/Component/Header.swift +545 -0
- package/ios/Navigation/Component/HeaderBackground.swift +154 -0
- package/ios/Navigation/Component/HeaderJourney.swift +73 -0
- package/ios/Navigation/Component/HeaderLocation.swift +48 -0
- package/ios/Navigation/Component/HeaderRight.swift +425 -0
- package/ios/Navigation/Component/HeaderTitle.swift +36 -0
- package/ios/{Application/Navigation → Navigation/Component}/HeaderUser.swift +21 -24
- package/ios/Navigation/Component/SideBar.swift +183 -0
- package/ios/{Application/Navigation/Overplay → Navigation/Component}/SnackBar.swift +8 -8
- package/ios/Navigation/HostBackGestureBridge.swift +78 -0
- package/ios/{Application/Navigation/Overplay → Navigation}/ModalScreen.swift +9 -2
- package/ios/Navigation/Navigation.swift +204 -0
- package/ios/Navigation/NavigationContainer.swift +195 -0
- package/ios/{Application/Navigation → Navigation}/Navigator.swift +207 -107
- package/ios/Navigation/StackScreen.swift +930 -0
- package/ios/Navigation/StackTransition.swift +400 -0
- package/ios/Navigation/SwipeBackHaptic.swift +33 -0
- package/ios/{Application/Navigation → Navigation}/Tracking/ScreenTracker.swift +12 -5
- package/ios/OTPKeyboard/OTPKeyboard.swift +8 -11
- package/ios/Pagination/PaginationScroll.swift +2 -2
- package/ios/Popup/PopupDisplay.swift +72 -135
- package/ios/Popup/PopupInput.swift +10 -13
- package/ios/Popup/PopupLayout.swift +140 -0
- package/ios/Popup/PopupNotify.swift +59 -69
- package/ios/Popup/PopupPromotion.swift +31 -47
- package/ios/PopupView/FullscreenPopup.swift +13 -19
- package/ios/PopupView/PopupView.swift +1 -1
- package/ios/PopupView/Utils++.swift +0 -8
- package/ios/ProgressInfo/ProgressInfo.swift +4 -2
- package/ios/Radio/Radio.swift +2 -1
- package/ios/Rating/Rating.swift +9 -1
- package/ios/Resources/dot_loading.json +558 -0
- package/ios/Resources/lottie_circle_loader.json +1 -0
- package/ios/ScrollIndicator/ScrollIndicator.swift +11 -10
- package/ios/Skeleton/Skeleton.swift +22 -27
- package/ios/Slider/Slider.swift +16 -18
- package/ios/Stepper/Stepper.swift +6 -1
- package/ios/Steps/Steps.swift +8 -6
- package/ios/SuggestAction/SuggestAction.swift +5 -0
- package/ios/Swipeable/SwipeCell.swift +10 -6
- package/ios/Switch/Switch.swift +22 -20
- package/ios/TabView/TabView.swift +102 -21
- package/ios/Tag/Tag.swift +15 -4
- package/ios/Template/Logo/Logo.swift +2 -1
- package/ios/Template/TrustBanner/TrustBanner.swift +106 -54
- package/ios/Title/Title.swift +6 -17
- package/ios/Tooltip/Tooltip.swift +29 -14
- package/ios/Typography/FontScale.swift +3 -3
- package/ios/Typography/Text.swift +81 -19
- package/ios/Uploader/Uploader.swift +3 -1
- package/ios/native-kits.podspec +2 -1
- package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
- package/ios-demo/README.md +120 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ContentView.swift +1 -1
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/DuoTabDemo.swift +80 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/Home.swift +2 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/IPhoneDuoDemo.swift +173 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/NavigationDemo.swift +77 -1
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupDisplayDemo.swift +1 -0
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupNotifyDemo.swift +2 -2
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupPromotionDemo.swift +2 -0
- package/package.json +1 -1
- package/scripts/gen-ios-icons.mjs +36 -0
- package/settings.gradle.kts +64 -0
- package/ios/Application/Components.swift +0 -270
- package/ios/Application/ComposeApi.swift +0 -22
- package/ios/Application/HeaderRight.swift +0 -306
- package/ios/Application/Navigation/BottomTab/BottomTab.swift +0 -104
- package/ios/Application/Navigation/HeaderBackProps.swift +0 -66
- package/ios/Application/Navigation/HeaderTitle.swift +0 -57
- package/ios/Application/Navigation/NavigationContainer.swift +0 -213
- package/ios/Application/Navigation/NavigationOptions.swift +0 -109
- package/ios/Application/Navigation/NavigatorManager.swift +0 -22
- package/ios/Application/Navigation/component/Header.swift +0 -178
- package/ios/Application/Navigation/component/HeaderBackground.swift +0 -83
- package/ios/Application/Navigation/component/HeaderColor.swift +0 -65
- package/ios/Application/Navigation/component/HeaderRightWidthKey.swift +0 -11
- package/ios/Application/Navigation/component/NavigationFooter.swift +0 -90
- package/ios/Application/Navigation/component/NavigationHeaderRight.swift +0 -196
- package/ios/Application/Screen.swift +0 -250
- package/ios/Application/StackScreen.swift +0 -492
- /package/ios/{Application/Navigation → Navigation}/BottomTab/CurvedContainer.swift +0 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
|
|
3
|
+
/// Rounded surface laid out on the kit grid (8pt gutter), inset by the screen margin.
|
|
4
|
+
/// Mirrors `vn.momo.kits.layout.Card`.
|
|
5
|
+
public struct CardView<Content: View>: View {
|
|
6
|
+
@Environment(\.appTheme) private var theme
|
|
7
|
+
|
|
8
|
+
// MARK: Lifecycle
|
|
9
|
+
|
|
10
|
+
public init(
|
|
11
|
+
useShadow: Bool = false,
|
|
12
|
+
backgroundImage: String? = nil,
|
|
13
|
+
backgroundColor: Color? = nil,
|
|
14
|
+
contentPadding: EdgeInsets = EdgeInsets(top: Spacing.M, leading: Spacing.M, bottom: Spacing.M, trailing: Spacing.M),
|
|
15
|
+
@ViewBuilder content: @escaping () -> Content
|
|
16
|
+
) {
|
|
17
|
+
self.useShadow = useShadow
|
|
18
|
+
self.backgroundImage = backgroundImage
|
|
19
|
+
self.backgroundColor = backgroundColor
|
|
20
|
+
self.contentPadding = contentPadding
|
|
21
|
+
self.content = content
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// MARK: Public
|
|
25
|
+
|
|
26
|
+
public var body: some View {
|
|
27
|
+
GridFlow(gutter: Spacing.S, content: content)
|
|
28
|
+
.padding(contentPadding)
|
|
29
|
+
.background(background)
|
|
30
|
+
.background(backgroundColor ?? theme.colors.background.surface)
|
|
31
|
+
.clipShape(RoundedRectangle(cornerRadius: Radius.M))
|
|
32
|
+
// Approximates Compose `shadow(6.dp)` elevation.
|
|
33
|
+
.shadow(color: Color.black.opacity(useShadow ? 0.12 : 0), radius: useShadow ? 6 : 0, x: 0, y: useShadow ? 3 : 0)
|
|
34
|
+
.padding(.horizontal, Spacing.M)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// MARK: Private
|
|
38
|
+
|
|
39
|
+
private let useShadow: Bool
|
|
40
|
+
private let backgroundImage: String?
|
|
41
|
+
private let backgroundColor: Color?
|
|
42
|
+
private let contentPadding: EdgeInsets
|
|
43
|
+
private let content: () -> Content
|
|
44
|
+
|
|
45
|
+
@ViewBuilder private var background: some View {
|
|
46
|
+
if let backgroundImage {
|
|
47
|
+
ImageView(backgroundImage, contentMode: .fill).clipped()
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -1,20 +1,31 @@
|
|
|
1
1
|
import SwiftUI
|
|
2
2
|
|
|
3
|
-
/// Grid metrics published by a layout container (`SectionView`) so span-based children
|
|
3
|
+
/// Grid metrics published by a layout container (`SectionView`, `CardView`) so span-based children
|
|
4
4
|
/// can size themselves. Mirrors `vn.momo.kits.layout.GridContext`.
|
|
5
5
|
public struct GridContext: Equatable {
|
|
6
6
|
public let numberOfColumns: Int
|
|
7
7
|
public let gutter: CGFloat
|
|
8
8
|
public let sizePerSpan: CGFloat
|
|
9
|
+
/// Pixels per point; span sizes are floored onto the pixel grid when it is set.
|
|
10
|
+
public let displayScale: CGFloat
|
|
9
11
|
|
|
10
|
-
public init(
|
|
12
|
+
public init(
|
|
13
|
+
numberOfColumns: Int = 12,
|
|
14
|
+
gutter: CGFloat = Spacing.M,
|
|
15
|
+
sizePerSpan: CGFloat = 0,
|
|
16
|
+
displayScale: CGFloat = 0
|
|
17
|
+
) {
|
|
11
18
|
self.numberOfColumns = numberOfColumns
|
|
12
19
|
self.gutter = gutter
|
|
13
20
|
self.sizePerSpan = sizePerSpan
|
|
21
|
+
self.displayScale = displayScale
|
|
14
22
|
}
|
|
15
23
|
|
|
16
24
|
public func sizeForSpan(_ span: Int) -> CGFloat {
|
|
17
|
-
sizePerSpan * CGFloat(span) + gutter * CGFloat(span - 1)
|
|
25
|
+
let size = sizePerSpan * CGFloat(span) + gutter * CGFloat(span - 1)
|
|
26
|
+
guard displayScale > 0 else { return size }
|
|
27
|
+
// Compose floors in pixels so a full row of spans never overflows by a sub-pixel.
|
|
28
|
+
return floor(size * displayScale) / displayScale
|
|
18
29
|
}
|
|
19
30
|
}
|
|
20
31
|
|
|
@@ -28,3 +39,176 @@ public extension EnvironmentValues {
|
|
|
28
39
|
set { self[GridContextKey.self] = newValue }
|
|
29
40
|
}
|
|
30
41
|
}
|
|
42
|
+
|
|
43
|
+
/// Measures the width it is given, publishes the grid for it and wraps children in a flow row.
|
|
44
|
+
/// Shared body of `SectionView` and `CardView` (Compose `gridColumns()` + `gridContext()` + `FlowRow`).
|
|
45
|
+
struct GridFlow<Content: View>: View {
|
|
46
|
+
let gutter: CGFloat
|
|
47
|
+
let content: () -> Content
|
|
48
|
+
|
|
49
|
+
@Environment(\.screenLayout) private var screenLayout
|
|
50
|
+
@Environment(\.displayScale) private var displayScale
|
|
51
|
+
/// The Compose `BoxWithConstraints` width.
|
|
52
|
+
@State private var availableWidth: CGFloat = 0
|
|
53
|
+
|
|
54
|
+
var body: some View {
|
|
55
|
+
flow
|
|
56
|
+
.environment(\.gridContext, grid)
|
|
57
|
+
.frame(maxWidth: .infinity, alignment: .topLeading)
|
|
58
|
+
.background(widthReader)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private var grid: GridContext {
|
|
62
|
+
let columns = screenLayout.contentWidth > 600 ? 24 : 12
|
|
63
|
+
let column = (availableWidth - gutter * CGFloat(columns - 1)) / CGFloat(columns)
|
|
64
|
+
return GridContext(
|
|
65
|
+
numberOfColumns: columns,
|
|
66
|
+
gutter: gutter,
|
|
67
|
+
sizePerSpan: max(0, column),
|
|
68
|
+
displayScale: displayScale
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
@ViewBuilder private var flow: some View {
|
|
73
|
+
if #available(iOS 16.0, *) {
|
|
74
|
+
FlowRowLayout(gutter: gutter) { content() }
|
|
75
|
+
} else {
|
|
76
|
+
_VariadicView.Tree(FlowRowFallback(gutter: gutter, maxWidth: availableWidth)) { content() }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private var widthReader: some View {
|
|
81
|
+
GeometryReader { proxy in
|
|
82
|
+
Color.clear.preference(key: GridWidthKey.self, value: proxy.size.width)
|
|
83
|
+
}
|
|
84
|
+
.onPreferenceChange(GridWidthKey.self) { availableWidth = $0 }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private struct GridWidthKey: PreferenceKey {
|
|
89
|
+
static var defaultValue: CGFloat = 0
|
|
90
|
+
|
|
91
|
+
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
|
92
|
+
value = max(value, nextValue())
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/// iOS 15 has no `Layout`: the children are measured, then pinned to the same rows as
|
|
97
|
+
/// `FlowRowLayout` through alignment guides.
|
|
98
|
+
private struct FlowRowFallback: _VariadicView_UnaryViewRoot {
|
|
99
|
+
let gutter: CGFloat
|
|
100
|
+
let maxWidth: CGFloat
|
|
101
|
+
|
|
102
|
+
func body(children: _VariadicView.Children) -> some View {
|
|
103
|
+
FlowRowPlacement(children: children, gutter: gutter, maxWidth: maxWidth)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private struct FlowRowPlacement: View {
|
|
108
|
+
let children: _VariadicView.Children
|
|
109
|
+
let gutter: CGFloat
|
|
110
|
+
let maxWidth: CGFloat
|
|
111
|
+
@State private var sizes: [AnyHashable: CGSize] = [:]
|
|
112
|
+
|
|
113
|
+
var body: some View {
|
|
114
|
+
let placement = place()
|
|
115
|
+
ZStack(alignment: .topLeading) {
|
|
116
|
+
ForEach(children) { child in
|
|
117
|
+
child
|
|
118
|
+
// FlowRowLayout proposes no height
|
|
119
|
+
.fixedSize(horizontal: false, vertical: true)
|
|
120
|
+
.background(
|
|
121
|
+
GeometryReader { proxy in
|
|
122
|
+
Color.clear.preference(key: FlowChildSizesKey.self, value: [child.id: proxy.size])
|
|
123
|
+
}
|
|
124
|
+
)
|
|
125
|
+
.alignmentGuide(.leading) { _ in -(placement.origins[child.id]?.x ?? 0) }
|
|
126
|
+
.alignmentGuide(.top) { _ in -(placement.origins[child.id]?.y ?? 0) }
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
.frame(height: placement.height, alignment: .topLeading)
|
|
130
|
+
.onPreferenceChange(FlowChildSizesKey.self) { sizes = $0 }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private func place() -> (origins: [AnyHashable: CGPoint], height: CGFloat) {
|
|
134
|
+
var origins: [AnyHashable: CGPoint] = [:]
|
|
135
|
+
var x: CGFloat = 0
|
|
136
|
+
var y: CGFloat = 0
|
|
137
|
+
var rowHeight: CGFloat = 0
|
|
138
|
+
for child in children {
|
|
139
|
+
let size = sizes[child.id] ?? .zero
|
|
140
|
+
// Sub-point tolerance: spans are computed to land exactly on `maxWidth`.
|
|
141
|
+
if x > 0, x + size.width > maxWidth + 0.5 {
|
|
142
|
+
x = 0
|
|
143
|
+
y += rowHeight + gutter
|
|
144
|
+
rowHeight = 0
|
|
145
|
+
}
|
|
146
|
+
origins[child.id] = CGPoint(x: x, y: y)
|
|
147
|
+
x += size.width + gutter
|
|
148
|
+
rowHeight = max(rowHeight, size.height)
|
|
149
|
+
}
|
|
150
|
+
return (origins, y + rowHeight)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private struct FlowChildSizesKey: PreferenceKey {
|
|
155
|
+
static var defaultValue: [AnyHashable: CGSize] = [:]
|
|
156
|
+
|
|
157
|
+
static func reduce(value: inout [AnyHashable: CGSize], nextValue: () -> [AnyHashable: CGSize]) {
|
|
158
|
+
value.merge(nextValue()) { $1 }
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/// Wrapping row with equal horizontal/vertical spacing — the Compose `FlowRow` equivalent.
|
|
163
|
+
@available(iOS 16.0, *)
|
|
164
|
+
private struct FlowRowLayout: Layout {
|
|
165
|
+
struct Row {
|
|
166
|
+
var items: [(index: Int, size: CGSize)] = []
|
|
167
|
+
var width: CGFloat = 0
|
|
168
|
+
var height: CGFloat = 0
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
let gutter: CGFloat
|
|
172
|
+
|
|
173
|
+
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize {
|
|
174
|
+
let maxWidth = proposal.width ?? .infinity
|
|
175
|
+
let rows = rows(for: subviews, maxWidth: maxWidth)
|
|
176
|
+
let height = rows.reduce(0) { $0 + $1.height } + gutter * CGFloat(max(0, rows.count - 1))
|
|
177
|
+
return CGSize(width: proposal.width ?? rows.map(\.width).max() ?? 0, height: height)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) {
|
|
181
|
+
var y = bounds.minY
|
|
182
|
+
for row in rows(for: subviews, maxWidth: bounds.width) {
|
|
183
|
+
var x = bounds.minX
|
|
184
|
+
for item in row.items {
|
|
185
|
+
subviews[item.index].place(
|
|
186
|
+
at: CGPoint(x: x, y: y),
|
|
187
|
+
anchor: .topLeading,
|
|
188
|
+
proposal: ProposedViewSize(item.size)
|
|
189
|
+
)
|
|
190
|
+
x += item.size.width + gutter
|
|
191
|
+
}
|
|
192
|
+
y += row.height + gutter
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private func rows(for subviews: Subviews, maxWidth: CGFloat) -> [Row] {
|
|
197
|
+
var rows: [Row] = []
|
|
198
|
+
var current = Row()
|
|
199
|
+
for index in subviews.indices {
|
|
200
|
+
let size = subviews[index].sizeThatFits(ProposedViewSize(width: maxWidth, height: nil))
|
|
201
|
+
let widthIfAppended = current.items.isEmpty ? size.width : current.width + gutter + size.width
|
|
202
|
+
// Sub-point tolerance: spans are computed to land exactly on `maxWidth`.
|
|
203
|
+
if !current.items.isEmpty, widthIfAppended > maxWidth + 0.5 {
|
|
204
|
+
rows.append(current)
|
|
205
|
+
current = Row()
|
|
206
|
+
}
|
|
207
|
+
current.width = current.items.isEmpty ? size.width : current.width + gutter + size.width
|
|
208
|
+
current.height = max(current.height, size.height)
|
|
209
|
+
current.items.append((index, size))
|
|
210
|
+
}
|
|
211
|
+
if !current.items.isEmpty { rows.append(current) }
|
|
212
|
+
return rows
|
|
213
|
+
}
|
|
214
|
+
}
|
package/ios/Layout/Item.swift
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import SwiftUI
|
|
2
2
|
|
|
3
|
-
/// Span-sized cell inside a `SectionView`. Reads the grid published by its container;
|
|
3
|
+
/// Span-sized cell inside a `SectionView` / `CardView`. Reads the grid published by its container;
|
|
4
4
|
/// without one it just wraps its content. Mirrors `vn.momo.kits.layout.Item`.
|
|
5
5
|
public struct ItemView<Content: View>: View {
|
|
6
6
|
// MARK: Lifecycle
|
|
7
7
|
|
|
8
|
+
/// `widthSpan` nil spans the full row, whatever the container's column count.
|
|
8
9
|
public init(
|
|
9
|
-
widthSpan: Int =
|
|
10
|
+
widthSpan: Int? = nil,
|
|
10
11
|
heightSpan: Int = 0,
|
|
11
12
|
@ViewBuilder content: @escaping () -> Content
|
|
12
13
|
) {
|
|
@@ -26,14 +27,14 @@ public struct ItemView<Content: View>: View {
|
|
|
26
27
|
|
|
27
28
|
// MARK: Private
|
|
28
29
|
|
|
29
|
-
private let widthSpan: Int
|
|
30
|
+
private let widthSpan: Int?
|
|
30
31
|
private let heightSpan: Int
|
|
31
32
|
private let content: () -> Content
|
|
32
33
|
|
|
33
34
|
@Environment(\.gridContext) private var grid
|
|
34
35
|
|
|
35
36
|
private var width: CGFloat? {
|
|
36
|
-
grid.map { $0.sizeForSpan(widthSpan) }
|
|
37
|
+
grid.map { $0.sizeForSpan(widthSpan ?? $0.numberOfColumns) }
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
private var height: CGFloat? {
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
//
|
|
2
|
+
// ScreenLayout.swift
|
|
3
|
+
// MoMoUIKits
|
|
4
|
+
//
|
|
5
|
+
// Port of momo-kits RN `useScreenLayout` (Layout/utils.ts) and `Application/device.ts`:
|
|
6
|
+
// window size, safe-area insets, content width, header geometry and the iPhone Duo side bar.
|
|
7
|
+
//
|
|
8
|
+
|
|
9
|
+
import SwiftUI
|
|
10
|
+
import UIKit
|
|
11
|
+
|
|
12
|
+
public enum SideBarEdge: Equatable {
|
|
13
|
+
case left, right
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
public struct SideBarLayout: Equatable {
|
|
17
|
+
public let edge: SideBarEdge
|
|
18
|
+
public let width: CGFloat
|
|
19
|
+
/// Preferred window y of the first control; StackScreen still keeps the column above the bottom bars.
|
|
20
|
+
public let top: CGFloat
|
|
21
|
+
/// Room kept free above the window bottom, clear of a camera at the bottom end of the strip.
|
|
22
|
+
public let bottom: CGFloat
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/// Room the header row keeps from each side for the status bar clock and battery clusters.
|
|
26
|
+
public struct StatusBarClearance: Equatable {
|
|
27
|
+
public let left: CGFloat
|
|
28
|
+
public let right: CGFloat
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
public struct ScreenLayout: Equatable {
|
|
32
|
+
public let width: CGFloat
|
|
33
|
+
public let height: CGFloat
|
|
34
|
+
public let isIPhoneDuo: Bool
|
|
35
|
+
public let insets: EdgeInsets
|
|
36
|
+
public let insetLeft: CGFloat
|
|
37
|
+
public let insetRight: CGFloat
|
|
38
|
+
public let contentWidth: CGFloat
|
|
39
|
+
public let headerHeight: CGFloat
|
|
40
|
+
/// 0 when the status bar is off the top edge, so the bar row fills the header and keeps the title centered.
|
|
41
|
+
public let headerStatusBarHeight: CGFloat
|
|
42
|
+
/// iPhone Duo moves back and toolkit into a column in the side safe area; nil while they stay in the bar.
|
|
43
|
+
public let sideBar: SideBarLayout?
|
|
44
|
+
/// iPhone Duo inner display in portrait: the header row shares the status bar band, between its clusters.
|
|
45
|
+
public let statusBarClearance: StatusBarClearance?
|
|
46
|
+
|
|
47
|
+
public var headerBarHeight: CGFloat { headerHeight - headerStatusBarHeight }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Measured by momo-kits RN (Layout/utils.ts) on the iOS 27.1 iPhone Duo simulator; keep both in sync.
|
|
51
|
+
enum DuoMetrics {
|
|
52
|
+
// Inner display is ~1.42, outer ~1.45; every slab iPhone is 1.77 or taller.
|
|
53
|
+
static let maxAspectRatio: CGFloat = 1.6
|
|
54
|
+
// Outer display is 466pt tall in landscape, inner 669pt.
|
|
55
|
+
static let innerMinHeight: CGFloat = 600
|
|
56
|
+
static let controlSize: CGFloat = 48
|
|
57
|
+
static let itemPitch: CGFloat = 50
|
|
58
|
+
static let firstItemTop: CGFloat = 170
|
|
59
|
+
static let firstItemTopLandscape: CGFloat = 120
|
|
60
|
+
// Outer display in portrait: the header is 4pt taller than the iPhone floor and its title
|
|
61
|
+
// lines up with the camera punchout (y 24–66 at the top of the strip).
|
|
62
|
+
static let outerHeaderHeight: CGFloat = 76
|
|
63
|
+
static let outerCameraCenterY: CGFloat = 45
|
|
64
|
+
// Outer display in landscape has no status bar in the strip: controls keep this far from
|
|
65
|
+
// both ends unless a reserved region (the camera at either end) asks for more.
|
|
66
|
+
static let edgeInset: CGFloat = 24
|
|
67
|
+
// Inner display in portrait: the clock and connectivity cluster sits at the top right corner
|
|
68
|
+
// (x 550–643 of 669, clock digits centered on y 47); its reserved region is taller than the glyphs.
|
|
69
|
+
static let innerStatusBarCenterY: CGFloat = 47
|
|
70
|
+
static let innerStatusBarWidth: CGFloat = 120
|
|
71
|
+
// Host app's iOS 26 tab bar on the inner display in portrait: a 62pt glass pill sized to its tabs,
|
|
72
|
+
// QR as a 62pt platter beside it, ≥ 20pt from the sides, 13pt into the home indicator inset.
|
|
73
|
+
static let floatingTabBarHeight: CGFloat = 62
|
|
74
|
+
static let floatingTabBarMargin: CGFloat = 20
|
|
75
|
+
static let floatingTabBarInsetOverlap: CGFloat = 13
|
|
76
|
+
// Widest short side of any single-display iPhone.
|
|
77
|
+
static let iPhoneMaxShortSide: CGFloat = 440
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
enum HeaderMetrics {
|
|
81
|
+
static let barHeight: CGFloat = HEADER_HEIGHT
|
|
82
|
+
static let iPhoneMinHeight: CGFloat = 72
|
|
83
|
+
// A smaller top inset means the status bar is not on the top edge.
|
|
84
|
+
static let statusBarMinInset: CGFloat = 20
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private var isPhoneIdiom: Bool { UIDevice.current.userInterfaceIdiom == .phone }
|
|
88
|
+
|
|
89
|
+
private var keyWindow: UIWindow? {
|
|
90
|
+
UIApplication.shared.connectedScenes
|
|
91
|
+
.compactMap { ($0 as? UIWindowScene)?.keyWindow }
|
|
92
|
+
.first
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
extension ScreenLayout {
|
|
96
|
+
/// Snapshot of the key window; not reactive, so views read `\.screenLayout` instead.
|
|
97
|
+
public static var current: ScreenLayout {
|
|
98
|
+
guard let window = keyWindow else {
|
|
99
|
+
return make(size: UIScreen.main.bounds.size, insets: EdgeInsets())
|
|
100
|
+
}
|
|
101
|
+
let inset = window.safeAreaInsets
|
|
102
|
+
return make(
|
|
103
|
+
size: window.bounds.size,
|
|
104
|
+
insets: EdgeInsets(top: inset.top, leading: inset.left, bottom: inset.bottom, trailing: inset.right)
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
static func make(
|
|
109
|
+
size: CGSize,
|
|
110
|
+
insets: EdgeInsets,
|
|
111
|
+
reservedRegions: [CGRect] = ReservedRegions.current,
|
|
112
|
+
isPhone: Bool = isPhoneIdiom
|
|
113
|
+
) -> ScreenLayout {
|
|
114
|
+
let width = size.width
|
|
115
|
+
let height = size.height
|
|
116
|
+
var (headerHeight, headerStatusBarHeight) = headerGeometry(topInset: insets.top, isPhone: isPhone)
|
|
117
|
+
let isDuo = isPhone && isIPhoneDuoWindow(width: width, height: height, insets: insets)
|
|
118
|
+
guard isDuo else {
|
|
119
|
+
return ScreenLayout(
|
|
120
|
+
width: width, height: height, isIPhoneDuo: false, insets: insets,
|
|
121
|
+
insetLeft: 0, insetRight: 0, contentWidth: width,
|
|
122
|
+
headerHeight: headerHeight, headerStatusBarHeight: headerStatusBarHeight, sideBar: nil,
|
|
123
|
+
statusBarClearance: nil
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
var statusBarClearance: StatusBarClearance?
|
|
128
|
+
let isInnerPortrait = height > width && width >= DuoMetrics.innerMinHeight
|
|
129
|
+
if isInnerPortrait && insets.top >= HeaderMetrics.statusBarMinInset {
|
|
130
|
+
statusBarClearance = statusBarClusters(reservedRegions, windowWidth: width, topInset: insets.top)
|
|
131
|
+
?? StatusBarClearance(left: 0, right: DuoMetrics.innerStatusBarWidth + Spacing.S)
|
|
132
|
+
// centered on the clock digits; the cluster's region center sits above them
|
|
133
|
+
headerHeight = DuoMetrics.innerStatusBarCenterY * 2
|
|
134
|
+
headerStatusBarHeight = 0
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
var sideBar: SideBarLayout?
|
|
138
|
+
let sideInset = max(insets.leading, insets.trailing)
|
|
139
|
+
// narrower strips cannot host a control 12pt from the inner edge
|
|
140
|
+
if sideInset >= DuoMetrics.controlSize + Spacing.M {
|
|
141
|
+
let edge: SideBarEdge = insets.trailing >= insets.leading ? .right : .left
|
|
142
|
+
let stripLeft = edge == .right ? width - sideInset : 0
|
|
143
|
+
// clear of the camera and of the status bar cluster, which grows for calls and Live
|
|
144
|
+
// Activities; region frames already include the system's margin
|
|
145
|
+
let occlusion = stripOcclusion(reservedRegions, stripLeft: stripLeft, stripRight: stripLeft + sideInset, windowHeight: height)
|
|
146
|
+
|
|
147
|
+
var top = statusBarClearance != nil
|
|
148
|
+
? headerHeight + Spacing.M
|
|
149
|
+
: insets.top + HeaderMetrics.barHeight + Spacing.M
|
|
150
|
+
if insets.top < HeaderMetrics.statusBarMinInset {
|
|
151
|
+
// status bar lives in the strip: start under its cluster, as the native bar does
|
|
152
|
+
if width <= height {
|
|
153
|
+
top = DuoMetrics.firstItemTop
|
|
154
|
+
} else if height >= DuoMetrics.innerMinHeight {
|
|
155
|
+
top = DuoMetrics.firstItemTopLandscape
|
|
156
|
+
} else {
|
|
157
|
+
// outer display in landscape: no status bar in the strip, the title row centers
|
|
158
|
+
// on the camera when it sits at the top end, otherwise on the back button
|
|
159
|
+
top = DuoMetrics.edgeInset
|
|
160
|
+
let titleCenterY = occlusion.top > top ? occlusion.topCenter : top + DuoMetrics.controlSize / 2
|
|
161
|
+
(headerHeight, headerStatusBarHeight) = centeredHeaderGeometry(centerY: titleCenterY)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if height > width && width < DuoMetrics.innerMinHeight {
|
|
165
|
+
// outer display: the row starts below the top so the title centers on the camera
|
|
166
|
+
headerHeight = DuoMetrics.outerHeaderHeight
|
|
167
|
+
headerStatusBarHeight = DuoMetrics.outerCameraCenterY * 2 - DuoMetrics.outerHeaderHeight
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
sideBar = SideBarLayout(
|
|
172
|
+
edge: edge,
|
|
173
|
+
width: sideInset,
|
|
174
|
+
top: max(top, occlusion.top),
|
|
175
|
+
bottom: max(DuoMetrics.edgeInset, occlusion.bottom)
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return ScreenLayout(
|
|
180
|
+
width: width, height: height, isIPhoneDuo: true, insets: insets,
|
|
181
|
+
insetLeft: insets.leading, insetRight: insets.trailing,
|
|
182
|
+
contentWidth: width - insets.leading - insets.trailing,
|
|
183
|
+
headerHeight: headerHeight, headerStatusBarHeight: headerStatusBarHeight, sideBar: sideBar,
|
|
184
|
+
statusBarClearance: statusBarClearance
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private static func headerGeometry(topInset: CGFloat, isPhone: Bool) -> (CGFloat, CGFloat) {
|
|
189
|
+
let height = topInset + HeaderMetrics.barHeight
|
|
190
|
+
guard isPhone else { return (height, topInset) }
|
|
191
|
+
return (
|
|
192
|
+
max(height, HeaderMetrics.iPhoneMinHeight),
|
|
193
|
+
topInset < HeaderMetrics.statusBarMinInset ? 0 : topInset
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/// Bar row centered on a window y, at least iPhoneMinHeight tall and reaching 8pt below
|
|
198
|
+
/// a side bar control centered there.
|
|
199
|
+
private static func centeredHeaderGeometry(centerY: CGFloat) -> (CGFloat, CGFloat) {
|
|
200
|
+
let height = max(HeaderMetrics.iPhoneMinHeight, centerY + DuoMetrics.controlSize / 2 + Spacing.S)
|
|
201
|
+
return (height, max(0, centerY * 2 - height))
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/// How far system regions reach into a side strip from its top and from its bottom end
|
|
205
|
+
/// (e.g. the camera, which sits at either end depending on the rotation), and the center y
|
|
206
|
+
/// of the region reaching furthest from the top.
|
|
207
|
+
private static func stripOcclusion(
|
|
208
|
+
_ regions: [CGRect],
|
|
209
|
+
stripLeft: CGFloat,
|
|
210
|
+
stripRight: CGFloat,
|
|
211
|
+
windowHeight: CGFloat
|
|
212
|
+
) -> (top: CGFloat, topCenter: CGFloat, bottom: CGFloat) {
|
|
213
|
+
regions.reduce(into: (top: CGFloat(0), topCenter: CGFloat(0), bottom: CGFloat(0))) { occlusion, region in
|
|
214
|
+
guard region.minX < stripRight && region.maxX > stripLeft else { return }
|
|
215
|
+
if region.midY < windowHeight / 2 {
|
|
216
|
+
if region.maxY > occlusion.top {
|
|
217
|
+
occlusion.top = region.maxY
|
|
218
|
+
occlusion.topCenter = region.midY
|
|
219
|
+
}
|
|
220
|
+
} else {
|
|
221
|
+
occlusion.bottom = max(occlusion.bottom, windowHeight - region.minY)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/// How far the clock and battery clusters of a status bar on the top edge reach in from each
|
|
227
|
+
/// side; nil without reserved regions there.
|
|
228
|
+
private static func statusBarClusters(_ regions: [CGRect], windowWidth: CGFloat, topInset: CGFloat) -> StatusBarClearance? {
|
|
229
|
+
let midX = windowWidth / 2
|
|
230
|
+
let clusters = regions.filter {
|
|
231
|
+
$0.minY < topInset && $0.maxY <= topInset && ($0.maxX <= midX || $0.minX >= midX)
|
|
232
|
+
}
|
|
233
|
+
guard !clusters.isEmpty else { return nil }
|
|
234
|
+
var left: CGFloat = 0
|
|
235
|
+
var right: CGFloat = 0
|
|
236
|
+
for region in clusters {
|
|
237
|
+
if region.minX < midX {
|
|
238
|
+
left = max(left, region.maxX)
|
|
239
|
+
} else {
|
|
240
|
+
right = max(right, windowWidth - region.minX)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return StatusBarClearance(left: left, right: right)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Slab iPhones report equal side insets in landscape; iPhone Duo moves the status bar to one side.
|
|
247
|
+
private static func isIPhoneDuoWindow(width: CGFloat, height: CGFloat, insets: EdgeInsets) -> Bool {
|
|
248
|
+
let shortSide = min(width, height)
|
|
249
|
+
let isSquare = shortSide > 0 && max(width, height) / shortSide < DuoMetrics.maxAspectRatio
|
|
250
|
+
return isSquare || insets.leading != insets.trailing
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/// Below the side status bar: in portrait it is about twice as tall as its inset is wide.
|
|
254
|
+
public var duoTopInset: CGFloat {
|
|
255
|
+
max(insets.top, height > width ? max(insets.leading, insets.trailing) * 2 : 0)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/// Port of RN `device.ts` `isIPhoneDuo()`: UIScreen stays on the outer display, so this
|
|
260
|
+
/// still matches when Split View narrows the window. Used by popups and overlays.
|
|
261
|
+
public func isIPhoneDuoDevice(windowSize: CGSize? = nil) -> Bool {
|
|
262
|
+
guard isPhoneIdiom else { return false }
|
|
263
|
+
let screen = UIScreen.main.bounds.size
|
|
264
|
+
let window = windowSize ?? keyWindow?.bounds.size ?? screen
|
|
265
|
+
return max(min(screen.width, screen.height), min(window.width, window.height)) > DuoMetrics.iPhoneMaxShortSide
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// MARK: - Reserved regions
|
|
269
|
+
|
|
270
|
+
/// System occlusion regions (iOS 27.1+) in window coordinates: the iPhone Duo camera, and the
|
|
271
|
+
/// status bar cluster as it grows for a call or a Live Activity. Empty before iOS 27.1.
|
|
272
|
+
enum ReservedRegions {
|
|
273
|
+
// last report of a probe, so `ScreenLayout.current` includes it
|
|
274
|
+
private(set) static var current: [CGRect] = []
|
|
275
|
+
static let didChange = Notification.Name("MoMoUIKitsReservedRegionsDidChange")
|
|
276
|
+
|
|
277
|
+
fileprivate static func publish(_ regions: [CGRect]) {
|
|
278
|
+
guard regions != current else { return }
|
|
279
|
+
current = regions
|
|
280
|
+
NotificationCenter.default.post(name: didChange, object: nil)
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/// Invisible probe: reading the regions in layoutSubviews makes UIKit run it again when they
|
|
285
|
+
/// change, so nothing polls. Port of RN `@momo-kits/reserved-regions`.
|
|
286
|
+
private final class ReservedRegionsProbe: UIView {
|
|
287
|
+
override init(frame: CGRect) {
|
|
288
|
+
super.init(frame: frame)
|
|
289
|
+
isUserInteractionEnabled = false
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
required init?(coder: NSCoder) { nil }
|
|
293
|
+
|
|
294
|
+
override func layoutSubviews() {
|
|
295
|
+
super.layoutSubviews()
|
|
296
|
+
// read even when not publishing, so UIKit keeps re-running this when they change
|
|
297
|
+
let regions = occlusionRegions()
|
|
298
|
+
// every screen has a probe but the regions are window-wide: one sliding in on a push or
|
|
299
|
+
// a present, or off the window, would overwrite them with a partial read
|
|
300
|
+
guard spansWindow else { return }
|
|
301
|
+
// layout pass: publish on the next turn instead of mutating SwiftUI state mid-update
|
|
302
|
+
DispatchQueue.main.async { ReservedRegions.publish(regions) }
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private var spansWindow: Bool {
|
|
306
|
+
guard let window else { return false }
|
|
307
|
+
let frame = convert(bounds, to: nil)
|
|
308
|
+
return abs(frame.minX) < 1 && abs(frame.maxX - window.bounds.width) < 1 && abs(frame.minY) < 1
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
override func didMoveToWindow() {
|
|
312
|
+
super.didMoveToWindow()
|
|
313
|
+
setNeedsLayout()
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// The API is newer than the SDKs hosts may build with, so it is reached through the runtime.
|
|
317
|
+
private func occlusionRegions() -> [CGRect] {
|
|
318
|
+
let regionsOfKind = NSSelectorFromString("reservedRegionsOfKind:")
|
|
319
|
+
guard window != nil, responds(to: regionsOfKind),
|
|
320
|
+
let kindType = NSClassFromString("UIViewReservedRegionKind") as? NSObject.Type,
|
|
321
|
+
let kind = (kindType as AnyObject).perform(NSSelectorFromString("occlusionRegionKind"))?.takeUnretainedValue(),
|
|
322
|
+
let regions = perform(regionsOfKind, with: kind)?.takeUnretainedValue() as? [NSObject]
|
|
323
|
+
else { return [] }
|
|
324
|
+
return regions.compactMap { ($0.value(forKey: "frame") as? NSValue)?.cgRectValue }
|
|
325
|
+
.map { convert($0, to: nil) }
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private struct ReservedRegionsProbeView: UIViewRepresentable {
|
|
330
|
+
func makeUIView(context: Context) -> ReservedRegionsProbe { ReservedRegionsProbe() }
|
|
331
|
+
|
|
332
|
+
func updateUIView(_ probe: ReservedRegionsProbe, context: Context) {}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// MARK: - Environment
|
|
336
|
+
|
|
337
|
+
private struct ScreenLayoutKey: EnvironmentKey {
|
|
338
|
+
// Computed so an un-injected subtree (bare UIHostingController) still reads the live window.
|
|
339
|
+
static var defaultValue: ScreenLayout { .current }
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
public extension EnvironmentValues {
|
|
343
|
+
var screenLayout: ScreenLayout {
|
|
344
|
+
get { self[ScreenLayoutKey.self] }
|
|
345
|
+
set { self[ScreenLayoutKey.self] = newValue }
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/// Re-reads the window whenever this view's size changes (rotation, fold/unfold, Split View).
|
|
350
|
+
private struct ScreenLayoutReader: ViewModifier {
|
|
351
|
+
@State private var layout: ScreenLayout = .current
|
|
352
|
+
|
|
353
|
+
func body(content: Content) -> some View {
|
|
354
|
+
content
|
|
355
|
+
.environment(\.screenLayout, layout)
|
|
356
|
+
.background(
|
|
357
|
+
GeometryReader { proxy in
|
|
358
|
+
Color.clear
|
|
359
|
+
.onAppear { refresh() }
|
|
360
|
+
.onChange(of: proxy.size) { _ in refresh() }
|
|
361
|
+
.onChange(of: proxy.safeAreaInsets) { _ in refresh() }
|
|
362
|
+
}
|
|
363
|
+
)
|
|
364
|
+
.background(ReservedRegionsProbeView().ignoresSafeArea())
|
|
365
|
+
// any screen's probe may report, so every reader follows the shared value
|
|
366
|
+
.onReceive(NotificationCenter.default.publisher(for: ReservedRegions.didChange)) { _ in
|
|
367
|
+
refresh()
|
|
368
|
+
}
|
|
369
|
+
.onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in
|
|
370
|
+
refresh()
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
private func refresh() {
|
|
375
|
+
let next = ScreenLayout.current
|
|
376
|
+
if next != layout { layout = next }
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
public extension View {
|
|
381
|
+
/// Provides `\.screenLayout`; NavigationContainer applies it, a bare UIHostingController root should too.
|
|
382
|
+
func momoScreenLayout() -> some View {
|
|
383
|
+
modifier(ScreenLayoutReader())
|
|
384
|
+
}
|
|
385
|
+
}
|