diff --git a/android/app/src/main/kotlin/kmp/android/MainActivity.kt b/android/app/src/main/kotlin/kmp/android/MainActivity.kt index d5e07758..9e93ce9d 100644 --- a/android/app/src/main/kotlin/kmp/android/MainActivity.kt +++ b/android/app/src/main/kotlin/kmp/android/MainActivity.kt @@ -1,13 +1,19 @@ package kmp.android +import SampleTabBarScreen import android.os.Bundle import androidx.activity.compose.setContent +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.core.view.WindowCompat -import kmp.android.di.initDependencyInjection +import androidx.lifecycle.compose.collectAsStateWithLifecycle import kmp.android.shared.core.system.BaseActivity -import kmp.android.shared.style.AppTheme -import kmp.android.ui.Root -import org.koin.core.context.GlobalContext +import kmp.shared.sampletabbar.presentation.ui.SampleTheme +import kmp.shared.sampletabbar.presentation.vm.SampleTabBarIntent +import kmp.shared.sampletabbar.presentation.vm.SampleTabBarViewModel +import kotlinx.coroutines.flow.collectLatest +import org.koin.androidx.compose.koinViewModel class MainActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -19,9 +25,34 @@ class MainActivity : BaseActivity() { override fun onStart() { super.onStart() setContent { - AppTheme { - Root() + SampleTheme { +// Root() + SampleTabBarMainRoute() } } } } + +@Composable +internal fun SampleTabBarMainRoute( + viewModel: SampleTabBarViewModel = koinViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + LaunchedEffect(key1 = viewModel) { + viewModel.onIntent(SampleTabBarIntent.OnAppeared) + } + + LaunchedEffect(key1 = viewModel) { + viewModel.events.collectLatest { event -> + when (event) { + else -> {} + } + } + } + + SampleTabBarScreen( + state = state, + onIntent = viewModel::onIntent, + ) +} diff --git a/build-logic/convention/src/main/kotlin/config/KmmConfig.kt b/build-logic/convention/src/main/kotlin/config/KmmConfig.kt index e8f8af38..0bb2c1a3 100644 --- a/build-logic/convention/src/main/kotlin/config/KmmConfig.kt +++ b/build-logic/convention/src/main/kotlin/config/KmmConfig.kt @@ -59,6 +59,7 @@ fun KotlinMultiplatformExtension.kmm( export(project(":shared:samplesharedviewmodel")) export(project(":shared:samplecomposemultiplatform")) export(project(":shared:samplecomposenavigation")) + export(project(":shared:sampletabbar")) } it.binaries { compilerOptions.freeCompilerArgs.add("-Xbinary=bundleId=kmp.shared.$nativeName") diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7124724f..62e85037 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,7 +16,7 @@ material-icons = "1.7.3" lifecycle = "2.9.3" paging = "3.3.6" composeBom = "2025.08.01" -jetbrains-composePlugin = "1.8.2" # Downgrade to 1.6.11 if you want to use compose multiplatform with UIKit navigation (it fixes the swipe back) +jetbrains-composePlugin = "1.9.0" activity = "1.10.1" navigation = "2.9.3" accompanist = "0.36.0" @@ -40,6 +40,8 @@ uiautomator = "2.3.0" skie = "0.10.6" firebase = "22.5.0" googleServices = "4.4.3" +coil = "3.3.0" +haze = "1.6.10" [libraries] # Kotlin @@ -93,8 +95,11 @@ activity-compose = { module = "androidx.activity:activity-compose", version.ref # Navigation navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation" } # Accompanist -accompanist-coil = { module = "com.google.accompanist:accompanist-coil", version.ref = "accompanist" } accompanist-navigationMaterial = { module = "com.google.accompanist:accompanist-navigation-material", version.ref = "accompanist" } +# Coil +coil = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } +coil-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" } +coil-ktor = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" } # Ktor ktor-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" } @@ -127,6 +132,9 @@ androidx-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomato skie-annotations = { module = "co.touchlab.skie:configuration-annotations", version.ref = "skie" } # Firebase firebase-analytics = { module = "com.google.firebase:firebase-analytics-ktx", version.ref = "firebase" } +# Haze +haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" } +haze-materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "haze" } [bundles] settings = [ diff --git a/ios/Application/DependencyInjection/Sources/DependencyInjection/KMPViewModels.swift b/ios/Application/DependencyInjection/Sources/DependencyInjection/KMPViewModels.swift index 2db12053..9208b3b2 100644 --- a/ios/Application/DependencyInjection/Sources/DependencyInjection/KMPViewModels.swift +++ b/ios/Application/DependencyInjection/Sources/DependencyInjection/KMPViewModels.swift @@ -14,4 +14,5 @@ public extension Container { // Sample var sampleSharedViewModel: Factory { self { self.kmp().get(SampleSharedViewModel.self) } } var sampleNextViewModel: Factory { self { self.kmp().get(SampleNextViewModel.self) } } + var sampleTabBarViewModel: Factory { self { self.kmp().get(SampleTabBarViewModel.self) } } } diff --git a/ios/Application/MainFlowController.swift b/ios/Application/MainFlowController.swift index 0db7206e..28bd61f0 100644 --- a/ios/Application/MainFlowController.swift +++ b/ios/Application/MainFlowController.swift @@ -3,6 +3,8 @@ // Copyright © 2019 Matee. All rights reserved. // +import DependencyInjection +import Factory import KMPShared import Sample import SampleComposeMultiplatform @@ -23,9 +25,12 @@ enum MainTab: Int { final class MainFlowController: FlowController { override func setup() -> UIViewController { - let main = UITabBarController() - main.viewControllers = [setupSampleTab(), setupSampleSharedViewModelTab(), setupSampleComposeMultiplatformTab(), setupSampleComposeNavigationTab()] - return main + @Injected(\.sampleTabBarViewModel) var viewModel: KMPShared.SampleTabBarViewModel + return UIHostingController(rootView: SampleTabBarView(flowController: self)) +// return UIHostingController(rootView: TestScreen()) +// let main = UITabBarController() +// main.viewControllers = [setupSampleTab(), setupSampleSharedViewModelTab(), setupSampleComposeMultiplatformTab(), setupSampleComposeNavigationTab()] +// return main } private func setupSampleTab() -> UINavigationController { diff --git a/ios/MateeStarter.xcodeproj/xcshareddata/xcschemes/MateeStarter_Alpha.xcscheme b/ios/MateeStarter.xcodeproj/xcshareddata/xcschemes/MateeStarter_Alpha.xcscheme index 29d6bef9..72b78793 100644 --- a/ios/MateeStarter.xcodeproj/xcshareddata/xcschemes/MateeStarter_Alpha.xcscheme +++ b/ios/MateeStarter.xcodeproj/xcshareddata/xcschemes/MateeStarter_Alpha.xcscheme @@ -26,7 +26,7 @@ ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction"> + scriptText = "cd "$SRCROOT/.." ./gradlew :shared:core:embedAndSignAppleFrameworkForXcode < /dev/null "> Void + let content: (Int32?) -> UIViewController + + @Published var toolbar: Toolbar? + @Published var tabs: [TabItem] + @Published var selectedTab: Int32 { + didSet { + if oldValue != selectedTab { + onSelectedTabChanged(selectedTab) + } + } + } + + init( + toolbar: Toolbar?, + tabs: [TabItem], + selectedTab: Int32, + onSelectedTabChanged: @escaping (Int32) -> Void, + content: @escaping (Int32?) -> UIViewController + ) { + self.tabs = tabs + self.selectedTab = selectedTab + self.onSelectedTabChanged = onSelectedTabChanged + self.content = content + } + + func updateToolbar(toolbar: Toolbar?) { + self.toolbar = toolbar + } + + func updateTabs(tabs: [TabItem]) { + self.tabs = tabs + } + + func updateSelectedTab(selectedTabPosition: Int32) { + self.selectedTab = selectedTabPosition + } +} diff --git a/ios/PresentationLayer/SampleComposeMultiplatform/Sources/SampleComposeMultiplatform/PlatformSpecificView/NativeScaffoldView.swift b/ios/PresentationLayer/SampleComposeMultiplatform/Sources/SampleComposeMultiplatform/PlatformSpecificView/NativeScaffoldView.swift new file mode 100644 index 00000000..68ccec18 --- /dev/null +++ b/ios/PresentationLayer/SampleComposeMultiplatform/Sources/SampleComposeMultiplatform/PlatformSpecificView/NativeScaffoldView.swift @@ -0,0 +1,100 @@ +// +// Created by Julia Jakubcova on 02/10/2025 +// Copyright © 2025 Matee. All rights reserved. +// + +import KMPShared +import SwiftUI +import UIToolkit + +struct NativeScaffoldView: View { + @ObservedObject var observable: NativeScaffoldObservable + + init(observable: NativeScaffoldObservable) { + self.observable = observable + } + + var body: some View { + if let toolbar = observable.toolbar { + if #available(iOS 16.0, *) { + Group { + if !observable.tabs.isEmpty { + TabView(selection: $observable.selectedTab) { + ForEach(observable.tabs, id: \.position) { tab in + Group { + if #available(iOS 26.0, *) { + ComposeViewController { observable.content(tab.position) } + .ignoresSafeArea() + } else { + ComposeViewController { observable.content(tab.position) } + } + } + .tabItem { + if let uiImage = tab.icon.toUIImage() { + Image(uiImage: uiImage) + } + Text(tab.title) + } + .tag(tab.position) + } + } + } else { + if #available(iOS 26.0, *) { + ComposeViewController { observable.content(nil) } + .ignoresSafeArea() + } else { + ComposeViewController { observable.content(nil) } + } + } + }.toolbar(toolbar) + } else { + // Fallback on earlier versions + } + } + } +} + +extension View { + @available(iOS 16.0, *) + func toolbar(_ toolbar: Toolbar) -> some View { + NavigationStack { + self + .navigationTitle(toolbar.title ?? "") + .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(.hidden, for: .navigationBar) + .toolbar { + let leading = toolbar.buttons.filter { $0.position == .leading } + let trailing = toolbar.buttons.filter { $0.position == .trailing } + + if !leading.isEmpty { + ToolbarItemGroup(placement: .topBarLeading) { + ForEach(Array(leading.enumerated()), id: \.offset) { _, button in + toolbarButtonView(for: button) + } + } + } + + if !trailing.isEmpty { + ToolbarItemGroup(placement: .topBarTrailing) { + ForEach(Array(trailing.enumerated()), id: \.offset) { _, button in + toolbarButtonView(for: button) + } + } + } + } + } + } + + @ViewBuilder + private func toolbarButtonView(for button: ToolbarButtonData) -> some View { + Button { + button.onClick() + } label: { + if let uiImage = button.icon.toUIImage() { + Image(uiImage: uiImage) + .renderingMode(.template) + } + } + .tint(button.tint.map { Color(kmpColor: $0) } ?? .primary) + } +} diff --git a/ios/PresentationLayer/SampleComposeMultiplatform/Sources/SampleComposeMultiplatform/PlatformSpecificView/ScreenWithBottomBarView.swift b/ios/PresentationLayer/SampleComposeMultiplatform/Sources/SampleComposeMultiplatform/PlatformSpecificView/ScreenWithBottomBarView.swift new file mode 100644 index 00000000..fcbe47f6 --- /dev/null +++ b/ios/PresentationLayer/SampleComposeMultiplatform/Sources/SampleComposeMultiplatform/PlatformSpecificView/ScreenWithBottomBarView.swift @@ -0,0 +1,72 @@ +// +// Created by Julia Jakubcova on 02/10/2025 +// Copyright © 2025 Matee. All rights reserved. +// + +import KMPShared +import SwiftUI +import UIToolkit + +struct ScreenWithBottomBarView: View { + @ObservedObject var observable: ScreenWithPlatformSpecificBottomBarObservable + + init(observable: ScreenWithPlatformSpecificBottomBarObservable) { + self.observable = observable + } + + var body: some View { + if #available(iOS 16.0, *) { + NavigationStack { + TabView(selection: $observable.selectedTab) { + ForEach(observable.tabs, id: \.position) { tab in + Group { + if #available(iOS 26.0, *) { + ComposeViewController { tab.content } + .ignoresSafeArea() + } else { + ComposeViewController { tab.content } + } + + // ScrollView { + // ForEach((0...20), id: \.self) { _ in + // Text("doiaw jmo dcposekoif eswop[ijj fe ss") + // Color.red + // .frame(height: 100) + // } + // } + } + .tabItem { + if let uiImage = tab.icon.toUIImage() { + Image(uiImage: uiImage) + } + Text(tab.title) + } + .tag(tab.position) + + } + } + .navigationTitle("Items") +// .toolbarTitleDisplayMode(.inline) + .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(.hidden, for: .navigationBar) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Cancel", systemImage: "xmark") { + // cancel action + } + .tint(.red) + } + + ToolbarItem(placement: .topBarTrailing) { + Button("Done", systemImage: "checkmark") { + // done action + } + .badge(3) + } + } + } + } else { + // Fallback on earlier versions + } + } +} diff --git a/ios/PresentationLayer/SampleComposeMultiplatform/Sources/SampleComposeMultiplatform/PlatformSpecificView/ScreenWithPlatformSpecificBottomBarObservable.swift b/ios/PresentationLayer/SampleComposeMultiplatform/Sources/SampleComposeMultiplatform/PlatformSpecificView/ScreenWithPlatformSpecificBottomBarObservable.swift new file mode 100644 index 00000000..235ecc2a --- /dev/null +++ b/ios/PresentationLayer/SampleComposeMultiplatform/Sources/SampleComposeMultiplatform/PlatformSpecificView/ScreenWithPlatformSpecificBottomBarObservable.swift @@ -0,0 +1,44 @@ +// +// Created by Julia Jakubcova on 30/09/2025 +// Copyright © 2025 Matee. All rights reserved. +// + +import KMPShared +import SwiftUI + +class ScreenWithPlatformSpecificBottomBarObservable: + ObservableObject, + ScreenWithPlatformSpecificBottomBarDelegate { + + @Published var tabs: [BottomBarTabForIos] + @Published var onSelectedTabChanged: (Int32) -> Void + @Published var selectedTab: Int32 { + didSet { + if oldValue != selectedTab { + onSelectedTabChanged(selectedTab) + } + } + } + + init( + tabs: [BottomBarTabForIos], + selectedTab: Int32, + onSelectedTabChanged: @escaping (Int32) -> Void + ) { + self.tabs = tabs + self.selectedTab = selectedTab + self.onSelectedTabChanged = onSelectedTabChanged + } + + func updateTabs(tabs: [BottomBarTabForIos]) { + self.tabs = tabs + } + + func updateOnSelectedTabChanged(onSelectedTabChanged: @escaping (KotlinInt) -> Void) { + self.onSelectedTabChanged = { onSelectedTabChanged(KotlinInt(value: $0)) } + } + + func updateSelectedTab(selectedTabPosition: Int32) { + self.selectedTab = selectedTabPosition + } +} diff --git a/ios/PresentationLayer/SampleComposeMultiplatform/Sources/SampleComposeMultiplatform/PlatformSpecificView/SwiftUIComposeMultiplatformViewFactory.swift b/ios/PresentationLayer/SampleComposeMultiplatform/Sources/SampleComposeMultiplatform/PlatformSpecificView/SwiftUIComposeMultiplatformViewFactory.swift index 4d39128f..5eb4a0e7 100644 --- a/ios/PresentationLayer/SampleComposeMultiplatform/Sources/SampleComposeMultiplatform/PlatformSpecificView/SwiftUIComposeMultiplatformViewFactory.swift +++ b/ios/PresentationLayer/SampleComposeMultiplatform/Sources/SampleComposeMultiplatform/PlatformSpecificView/SwiftUIComposeMultiplatformViewFactory.swift @@ -20,4 +20,21 @@ public class SwiftUISampleComposeMultiplatformViewFactory: ComposeSampleComposeM return KotlinPair(first: viewController, second: observable) } + + public func createScreenWithPlatformSpecificBottomBar( + tabs: [BottomBarTabForIos], + selectedTabPosition: Int32, + onSelectedTabChanged: @escaping (KotlinInt) -> Void + ) -> KotlinPair { + let observable = ScreenWithPlatformSpecificBottomBarObservable( + tabs: tabs, + selectedTab: selectedTabPosition, + onSelectedTabChanged: { onSelectedTabChanged(KotlinInt(value: $0)) } + ) + let viewController: UIViewController = UIHostingController( + rootView: ScreenWithBottomBarView(observable: observable) + ) + + return KotlinPair(first: viewController, second: observable) + } } diff --git a/ios/PresentationLayer/SampleComposeMultiplatform/Sources/SampleComposeMultiplatform/PlatformSpecificView/SwiftUIFactory.swift b/ios/PresentationLayer/SampleComposeMultiplatform/Sources/SampleComposeMultiplatform/PlatformSpecificView/SwiftUIFactory.swift new file mode 100644 index 00000000..2737fe17 --- /dev/null +++ b/ios/PresentationLayer/SampleComposeMultiplatform/Sources/SampleComposeMultiplatform/PlatformSpecificView/SwiftUIFactory.swift @@ -0,0 +1,33 @@ +// +// Created by Julia Jakubcova on 25/11/2024 +// Copyright © 2024 Matee. All rights reserved. +// + +import KMPShared +import SwiftUI + +public class SwiftUIViewFactory: ComposeViewFactory { + + public init() {} + + public func createNativeScaffold( + toolbar: Toolbar?, + tabs: [TabItem], + selectedTabPosition: Int32, + onTabSelected: @escaping (KotlinInt) -> Void, + content: @escaping (KotlinInt?) -> UIViewController + ) -> KotlinPair { + let observable = NativeScaffoldObservable( + toolbar: toolbar, + tabs: tabs, + selectedTab: selectedTabPosition, + onSelectedTabChanged: { onTabSelected(KotlinInt(value: $0)) }, + content: { position in content(position.map { KotlinInt(value: $0) }) } + ) + let viewController: UIViewController = UIHostingController( + rootView: NativeScaffoldView(observable: observable) + ) + + return KotlinPair(first: viewController, second: observable) + } +} diff --git a/ios/PresentationLayer/SampleComposeMultiplatform/Sources/SampleComposeMultiplatform/SampleComposeMultiplatformView.swift b/ios/PresentationLayer/SampleComposeMultiplatform/Sources/SampleComposeMultiplatform/SampleComposeMultiplatformView.swift index 191128b9..22165f44 100644 --- a/ios/PresentationLayer/SampleComposeMultiplatform/Sources/SampleComposeMultiplatform/SampleComposeMultiplatformView.swift +++ b/ios/PresentationLayer/SampleComposeMultiplatform/Sources/SampleComposeMultiplatform/SampleComposeMultiplatformView.swift @@ -34,6 +34,7 @@ struct SampleComposeMultiplatformView: View { ) } .frame(maxWidth: .infinity, maxHeight: .infinity) + .ignoresSafeArea() .toastView($toastData) .navigationTitle(MR.strings().bottom_bar_item_3.toLocalized()) } diff --git a/ios/PresentationLayer/SampleComposeMultiplatform/Sources/SampleComposeMultiplatform/SampleTabBarView.swift b/ios/PresentationLayer/SampleComposeMultiplatform/Sources/SampleComposeMultiplatform/SampleTabBarView.swift new file mode 100644 index 00000000..ff174f4a --- /dev/null +++ b/ios/PresentationLayer/SampleComposeMultiplatform/Sources/SampleComposeMultiplatform/SampleTabBarView.swift @@ -0,0 +1,37 @@ +// +// Created by Julia Jakubcova on 06/10/2025 +// Copyright © 2025 Matee. All rights reserved. +// + +import Factory +import KMPShared +import SwiftUI +import UIToolkit + +public struct SampleTabBarView: View { + + private weak var flowController: FlowController? + + @State private var toastData: ToastData? + + public init(flowController: FlowController?) { + self.flowController = flowController + } + + public var body: some View { + ComposeViewController { + SampleTabBarViewController( + onEvent: { event in + switch onEnum(of: event) { + case .else: + print("Unknown event: \(event)") + } + }, + factory: SwiftUIViewFactory() + ) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .ignoresSafeArea() + .toastView($toastData) + } +} diff --git a/ios/PresentationLayer/SampleComposeMultiplatform/Sources/SampleComposeMultiplatform/TestScreen.swift b/ios/PresentationLayer/SampleComposeMultiplatform/Sources/SampleComposeMultiplatform/TestScreen.swift new file mode 100644 index 00000000..0042a848 --- /dev/null +++ b/ios/PresentationLayer/SampleComposeMultiplatform/Sources/SampleComposeMultiplatform/TestScreen.swift @@ -0,0 +1,88 @@ +// +// Created by Julia Jakubcova on 08/10/2025 +// Copyright © 2025 Matee. All rights reserved. +// + +import SwiftUI + +public struct TestScreen: View { + + @State private var selectedTab = 0 + + public init() {} + + public var body: some View { + if #available(iOS 16.0, *) { + NavigationStack { + TabView(selection: $selectedTab) { + ForEach((0...2), id: \.self) { tab in + ScrollView { + ForEach((0...20), id: \.self) { index in + VStack(alignment: .leading) { + Text("This is item number \(index)") + .font(.largeTitle) + .padding([.top, .horizontal]) + + Text("And this is it's body") + .padding([.bottom, .horizontal]) + } + .frame(maxWidth: .infinity) + .background(.gray.opacity(0.5)) + .clipShape(.rect(cornerRadius: 8)) + .padding(.horizontal) + } + } + .tabItem { + Image(systemName: image(index: tab)) + Text(text(index: tab)) + } + .tag(tab) + + } + } + .navigationTitle("Title") + .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(.hidden, for: .navigationBar) + .toolbar { + ToolbarItemGroup(placement: .topBarLeading) { + Button("First", systemImage: "magnifyingglass") { + + } + .tint(.red) + } + + ToolbarItemGroup(placement: .topBarTrailing) { + Button("Second", systemImage: "person") { + + } + } + + ToolbarItemGroup(placement: .topBarTrailing) { + Button("Second", systemImage: "house") { + + } + .tint(.yellow) + } + } + } + } else { + // Fallback on earlier versions + } + } + + func image(index: Int) -> String { + switch index { + case 0: return "house" + case 1: return "magnifyingglass" + default: return "person" + } + } + + func text(index: Int) -> String { + switch index { + case 0: return "Numbers" + case 1: return "Sentences" + default: return "Images" + } + } +} diff --git a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Extensions/Color+Extensions.swift b/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Extensions/Color+Extensions.swift new file mode 100644 index 00000000..2e6c6cbe --- /dev/null +++ b/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Extensions/Color+Extensions.swift @@ -0,0 +1,14 @@ +// +// Created by Julia Jakubcova on 08/10/2025 +// Copyright © 2025 Matee. All rights reserved. +// + +import KMPShared +import SwiftUI +import UIKit + +public extension Color { + init(kmpColor: KMPShared.NativeColor) { + self = Color(uiColor: kmpColor.toUIColor()) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 548f1582..6a8d4a99 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -31,3 +31,4 @@ include(":shared:sample") include(":shared:samplesharedviewmodel") include(":shared:samplecomposemultiplatform") include(":shared:samplecomposenavigation") +include(":shared:sampletabbar") diff --git a/shared/base/src/commonMain/moko-resources/images/home.svg b/shared/base/src/commonMain/moko-resources/images/home.svg new file mode 100644 index 00000000..8a3ab49b --- /dev/null +++ b/shared/base/src/commonMain/moko-resources/images/home.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shared/base/src/commonMain/moko-resources/images/person.svg b/shared/base/src/commonMain/moko-resources/images/person.svg new file mode 100644 index 00000000..338881d5 --- /dev/null +++ b/shared/base/src/commonMain/moko-resources/images/person.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shared/base/src/commonMain/moko-resources/images/search.svg b/shared/base/src/commonMain/moko-resources/images/search.svg new file mode 100644 index 00000000..b78a3fe1 --- /dev/null +++ b/shared/base/src/commonMain/moko-resources/images/search.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shared/core/build.gradle.kts b/shared/core/build.gradle.kts index 70cf1c74..866c10ad 100644 --- a/shared/core/build.gradle.kts +++ b/shared/core/build.gradle.kts @@ -39,4 +39,6 @@ dependencies { commonMainApi(project(":shared:samplesharedviewmodel")) commonMainApi(project(":shared:samplecomposemultiplatform")) commonMainApi(project(":shared:samplecomposenavigation")) + commonMainApi(project(":shared:sampletabbar")) + commonMainApi(project(":shared:sampletabbar")) } diff --git a/shared/core/src/androidUnitTest/kotlin/konsistTest/android/compose/ComposeTest.kt b/shared/core/src/androidUnitTest/kotlin/konsistTest/android/compose/ComposeTest.kt index f49d34cd..66b08440 100644 --- a/shared/core/src/androidUnitTest/kotlin/konsistTest/android/compose/ComposeTest.kt +++ b/shared/core/src/androidUnitTest/kotlin/konsistTest/android/compose/ComposeTest.kt @@ -29,7 +29,7 @@ internal class ComposeTest { .withType { it.name == "Modifier" } .let { params -> params.size == 1 && - params.all { param -> param.hasDefaultValue() && param.name == "modifier" } + params.all { param -> (param.hasDefaultValue() || fn.hasActualModifier) && param.name == "modifier" } } } } diff --git a/shared/core/src/commonMain/kotlin/kmp/shared/core/di/Module.kt b/shared/core/src/commonMain/kotlin/kmp/shared/core/di/Module.kt index 7ac69604..805ff76b 100644 --- a/shared/core/src/commonMain/kotlin/kmp/shared/core/di/Module.kt +++ b/shared/core/src/commonMain/kotlin/kmp/shared/core/di/Module.kt @@ -7,6 +7,7 @@ import kmp.shared.sample.di.sampleModule import kmp.shared.samplecomposemultiplatform.di.sampleComposeMultiplatformModule import kmp.shared.samplecomposenavigation.di.sampleComposeNavigationModule import kmp.shared.samplesharedviewmodel.di.sampleSharedViewModelModule +import kmp.shared.sampletabbar.di.sampleTabBarModule import org.koin.core.KoinApplication import org.koin.core.context.startKoin import org.koin.dsl.KoinAppDeclaration @@ -22,6 +23,7 @@ fun initKoin(appDeclaration: KoinAppDeclaration = {}): KoinApplication { analyticsModule, sampleComposeMultiplatformModule, sampleComposeNavigationModule, + sampleTabBarModule, ) } diff --git a/shared/samplecomposemultiplatform/build.gradle.kts b/shared/samplecomposemultiplatform/build.gradle.kts index 52abcff1..a5549b6d 100644 --- a/shared/samplecomposemultiplatform/build.gradle.kts +++ b/shared/samplecomposemultiplatform/build.gradle.kts @@ -24,6 +24,7 @@ dependencies { commonMainImplementation(compose.runtime) commonMainImplementation(compose.foundation) commonMainImplementation(compose.material) + commonMainImplementation(compose.material3) @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class) commonMainImplementation(compose.components.resources) commonMainImplementation(compose.components.uiToolingPreview) diff --git a/shared/samplecomposemultiplatform/src/androidMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/ScreenWithPlatformSpecificBottomBar.android.kt b/shared/samplecomposemultiplatform/src/androidMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/ScreenWithPlatformSpecificBottomBar.android.kt new file mode 100644 index 00000000..330368cc --- /dev/null +++ b/shared/samplecomposemultiplatform/src/androidMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/ScreenWithPlatformSpecificBottomBar.android.kt @@ -0,0 +1,54 @@ +package kmp.shared.samplecomposemultiplatform.presentation.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource + +@Composable +actual fun ScreenWithPlatformSpecificBottomBar( + tabs: List, + selectedTabPosition: Int, + onSelectedTabChanged: (Int) -> Unit, + modifier: Modifier, +) { + val selectedTab = tabs.firstOrNull { it.position == selectedTabPosition } + Column(modifier = modifier) { + Box(modifier = Modifier.weight(1f)) { + selectedTab?.content?.invoke() + } + + NavigationBar( + modifier = Modifier.fillMaxWidth(), + ) { + tabs.forEach { tab -> + NavigationBarItem( + selected = selectedTabPosition == tab.position, + icon = { + Icon( + painter = painterResource(tab.icon.drawableResId), + contentDescription = tab.title, + ) + }, + onClick = { onSelectedTabChanged(tab.position) }, + label = { + Text(tab.title) + }, + modifier = Modifier.padding( + bottom = WindowInsets.systemBars.asPaddingValues().calculateBottomPadding(), + ), + ) + } + } + } +} diff --git a/shared/samplecomposemultiplatform/src/commonMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/SampleComposeMultiplatformScreen.kt b/shared/samplecomposemultiplatform/src/commonMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/SampleComposeMultiplatformScreen.kt index 279572b8..ed15f9f2 100644 --- a/shared/samplecomposemultiplatform/src/commonMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/SampleComposeMultiplatformScreen.kt +++ b/shared/samplecomposemultiplatform/src/commonMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/SampleComposeMultiplatformScreen.kt @@ -1,6 +1,7 @@ package kmp.shared.samplecomposemultiplatform.presentation.ui import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -8,17 +9,23 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeContentPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import kmp.shared.base.MR import kmp.shared.samplecomposemultiplatform.presentation.common.AppTheme import kmp.shared.samplecomposemultiplatform.presentation.common.StarterButton import kmp.shared.samplecomposemultiplatform.presentation.ui.test.TestTags @@ -38,33 +45,90 @@ fun SampleComposeMultiplatformScreen( if (loading) { CircularProgressIndicator() } else { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.padding(16.dp), - ) { - Text( - text = "This is a sample with compose multiplatform UI and shared VM", - textAlign = TextAlign.Center, - ) + var selected by remember { mutableIntStateOf(0) } + ScreenWithPlatformSpecificBottomBar( + tabs = listOf( + BottomBarTab( + title = "Home", + icon = MR.images.home, + position = 0, + content = { + Content( + state = state, + onIntent = onIntent, + modifier = Modifier.background(Color.Yellow.copy(alpha = 0.2f)), + ) + }, + ), + BottomBarTab( + title = "Search", + icon = MR.images.search, + position = 1, + content = { + Content( + state = state, + onIntent = onIntent, + modifier = Modifier.background(Color.Blue.copy(alpha = 0.2f)), + ) + }, + ), + BottomBarTab( + title = "Profile", + icon = MR.images.person, + position = 2, + content = { + Content( + state = state, + onIntent = onIntent, + modifier = Modifier.background(Color.Cyan.copy(alpha = 0.2f)), + ) + }, + ), + ), + selectedTabPosition = selected, + onSelectedTabChanged = { selected = it }, + modifier = Modifier.fillMaxSize(), + ) + } + } + } +} + +@Composable +private fun Content( + state: SampleSharedState, + onIntent: (SampleSharedIntent) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = modifier + .verticalScroll(rememberScrollState()) + .padding(16.dp) + .safeContentPadding(), + ) { + repeat(10) { + Text( + text = "This is a sample with compose multiplatform UI and shared VM", + textAlign = TextAlign.Center, + ) - Text( - text = state.sampleText?.value ?: "", - modifier = Modifier.testTag(TestTags.SampleComposeMultiplatformScreen.SampleText), - ) + Text( + text = state.sampleText?.value ?: "", + modifier = Modifier.testTag(TestTags.SampleComposeMultiplatformScreen.SampleText), + ) - var isChecked by remember { mutableStateOf(false) } - PlatformSpecificCheckboxView( - text = "This is a view implemented in Compose on Android and SwiftUI on iOS", - checked = isChecked, - onCheckedChanged = { isChecked = it }, - modifier = Modifier.fillMaxWidth().height(60.dp), - ) + var isChecked by remember { mutableStateOf(false) } + PlatformSpecificCheckboxView( + text = "This is a view implemented in Compose on Android and SwiftUI on iOS", + checked = isChecked, + onCheckedChanged = { isChecked = it }, + modifier = Modifier.fillMaxWidth().height(60.dp), + ) - StarterButton(onClick = { onIntent(SampleSharedIntent.OnNextButtonTapped) }) { - Text(text = "Go to next screen") - } - } + StarterButton(onClick = { onIntent(SampleSharedIntent.OnNextButtonTapped) }) { + Text(text = "Go to next screen") } } } diff --git a/shared/samplecomposemultiplatform/src/commonMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/ScreenWithPlatformSpecificBottomBar.kt b/shared/samplecomposemultiplatform/src/commonMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/ScreenWithPlatformSpecificBottomBar.kt new file mode 100644 index 00000000..50b47eda --- /dev/null +++ b/shared/samplecomposemultiplatform/src/commonMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/ScreenWithPlatformSpecificBottomBar.kt @@ -0,0 +1,20 @@ +package kmp.shared.samplecomposemultiplatform.presentation.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import dev.icerock.moko.resources.ImageResource + +@Composable +expect fun ScreenWithPlatformSpecificBottomBar( + tabs: List, + selectedTabPosition: Int, + onSelectedTabChanged: (Int) -> Unit, + modifier: Modifier = Modifier, +) + +data class BottomBarTab( + val title: String, + val icon: ImageResource, + val position: Int, + val content: @Composable () -> Unit, +) diff --git a/shared/samplecomposemultiplatform/src/iosMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/SampleComposeMultiplatformScreenViewController.kt b/shared/samplecomposemultiplatform/src/iosMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/SampleComposeMultiplatformScreenViewController.kt index 3ad41758..a13caf00 100644 --- a/shared/samplecomposemultiplatform/src/iosMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/SampleComposeMultiplatformScreenViewController.kt +++ b/shared/samplecomposemultiplatform/src/iosMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/SampleComposeMultiplatformScreenViewController.kt @@ -2,8 +2,10 @@ package kmp.shared.samplecomposemultiplatform.presentation import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.ExperimentalComposeApi import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.window.ComposeUIViewController import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -18,12 +20,17 @@ import kotlinx.coroutines.flow.collectLatest import org.koin.compose.viewmodel.koinViewModel import platform.UIKit.UIViewController +@OptIn(ExperimentalComposeApi::class, ExperimentalComposeUiApi::class) @Suppress("Unused", "FunctionName") fun SampleComposeMultiplatformScreenViewController( onEvent: (SampleSharedEvent) -> Unit, factory: SampleComposeMultiplatformViewFactory, ): UIViewController { - return ComposeUIViewController { + return ComposeUIViewController( + configure = { + this.opaque = false + }, + ) { SampleComposeMultiplatformView( onEvent = onEvent, factory = factory, diff --git a/shared/samplecomposemultiplatform/src/iosMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/BottomBarTabForIos.kt b/shared/samplecomposemultiplatform/src/iosMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/BottomBarTabForIos.kt new file mode 100644 index 00000000..8a1461c0 --- /dev/null +++ b/shared/samplecomposemultiplatform/src/iosMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/BottomBarTabForIos.kt @@ -0,0 +1,11 @@ +package kmp.shared.samplecomposemultiplatform.presentation.ui + +import dev.icerock.moko.resources.ImageResource +import platform.UIKit.UIViewController + +data class BottomBarTabForIos( + val title: String, + val icon: ImageResource, + val position: Int, + val content: UIViewController, +) diff --git a/shared/samplecomposemultiplatform/src/iosMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/ComposeSampleComposeMultiplatformViewFactory.kt b/shared/samplecomposemultiplatform/src/iosMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/ComposeSampleComposeMultiplatformViewFactory.kt index dcc30806..19941f23 100644 --- a/shared/samplecomposemultiplatform/src/iosMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/ComposeSampleComposeMultiplatformViewFactory.kt +++ b/shared/samplecomposemultiplatform/src/iosMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/ComposeSampleComposeMultiplatformViewFactory.kt @@ -9,4 +9,10 @@ actual interface ComposeSampleComposeMultiplatformViewFactory { checked: Boolean, onCheckedChanged: (Boolean) -> Unit, ): Pair + + fun createScreenWithPlatformSpecificBottomBar( + tabs: List, + selectedTabPosition: Int, + onSelectedTabChanged: (Int) -> Unit, + ): Pair } diff --git a/shared/samplecomposemultiplatform/src/iosMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/ScreenWithPlatformSpecificBottomBar.ios.kt b/shared/samplecomposemultiplatform/src/iosMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/ScreenWithPlatformSpecificBottomBar.ios.kt new file mode 100644 index 00000000..8141a2af --- /dev/null +++ b/shared/samplecomposemultiplatform/src/iosMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/ScreenWithPlatformSpecificBottomBar.ios.kt @@ -0,0 +1,67 @@ +package kmp.shared.samplecomposemultiplatform.presentation.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.UIKitViewController +import androidx.compose.ui.window.ComposeUIViewController +import androidx.lifecycle.viewmodel.compose.viewModel +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.readValue +import platform.UIKit.UIColor +import platform.UIKit.UIEdgeInsetsZero +import platform.UIKit.setAdditionalSafeAreaInsets +import kotlin.random.Random + +@OptIn(ExperimentalForeignApi::class) +@Composable +actual fun ScreenWithPlatformSpecificBottomBar( + tabs: List, + selectedTabPosition: Int, + onSelectedTabChanged: (Int) -> Unit, + modifier: Modifier, +) { + val factory = LocalSampleComposeMultiplatformViewFactory.current + val key = rememberSaveable { Random.nextInt().toString(16) } + + val viewModel = viewModel(key = key) { + NativeViewHolderViewModel { + factory.createScreenWithPlatformSpecificBottomBar( + tabs = tabs.map { it.toIosTab(factory) }, + selectedTabPosition = selectedTabPosition, + onSelectedTabChanged = onSelectedTabChanged, + ) + } + } + val delegate = remember(viewModel) { viewModel.delegate } + val view = remember(viewModel) { viewModel.view } + + remember(tabs) { delegate.updateTabs(tabs.map { it.toIosTab(factory) }) } + remember(selectedTabPosition) { delegate.updateSelectedTab(selectedTabPosition) } + remember(onSelectedTabChanged) { delegate.updateOnSelectedTabChanged(onSelectedTabChanged) } + UIKitViewController( + modifier = modifier, + factory = { view }, + update = { controller -> + controller.view.backgroundColor = UIColor.clearColor + controller.view.opaque = false + controller.setAdditionalSafeAreaInsets(UIEdgeInsetsZero.readValue()) + }, + ) +} + +private fun BottomBarTab.toIosTab(factory: ComposeSampleComposeMultiplatformViewFactory): BottomBarTabForIos = + BottomBarTabForIos( + title = title, + icon = icon, + position = position, + content = ComposeUIViewController { + CompositionLocalProvider( + LocalSampleComposeMultiplatformViewFactory provides factory, + ) { + content() + } + }, + ) diff --git a/shared/samplecomposemultiplatform/src/iosMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/ScreenWithPlatformSpecificBottomBarDelegate.kt b/shared/samplecomposemultiplatform/src/iosMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/ScreenWithPlatformSpecificBottomBarDelegate.kt new file mode 100644 index 00000000..08c1e8ed --- /dev/null +++ b/shared/samplecomposemultiplatform/src/iosMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/ScreenWithPlatformSpecificBottomBarDelegate.kt @@ -0,0 +1,7 @@ +package kmp.shared.samplecomposemultiplatform.presentation.ui + +interface ScreenWithPlatformSpecificBottomBarDelegate { + fun updateTabs(tabs: List) + fun updateSelectedTab(selectedTabPosition: Int) + fun updateOnSelectedTabChanged(onSelectedTabChanged: (Int) -> Unit) +} diff --git a/shared/sampletabbar/build.gradle.kts b/shared/sampletabbar/build.gradle.kts new file mode 100644 index 00000000..0b53678f --- /dev/null +++ b/shared/sampletabbar/build.gradle.kts @@ -0,0 +1,39 @@ +plugins { + alias(libs.plugins.mateeStarter.kmm.library) + alias(libs.plugins.jetbrains.compose) + alias(libs.plugins.jetbrains.compose.compiler) +} + +android { + namespace = "kmp.shared.sampletabbar" +} + +ktlint { + filter { + exclude { entry -> + entry.file.toString().contains("generated") + } + } +} + +dependencies { + commonMainImplementation(project(":shared:base")) + commonMainImplementation(project(":shared:sample")) + commonMainImplementation(project(":shared:samplesharedviewmodel")) + commonMainImplementation(project(":shared:samplecomposemultiplatform")) + + commonMainImplementation(compose.runtime) + commonMainImplementation(compose.foundation) + commonMainImplementation(compose.material) + commonMainImplementation(compose.material3) + @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class) + commonMainImplementation(compose.components.resources) + commonMainImplementation(compose.components.uiToolingPreview) + ktlintRuleset(libs.ktlint.composeRules) + + commonMainImplementation(libs.coil) + androidMainImplementation(libs.coil.okhttp) + iosMainImplementation(libs.coil.ktor) + commonMainImplementation(libs.haze) + commonMainImplementation(libs.haze.materials) +} diff --git a/shared/sampletabbar/src/androidMain/kotlin/kmp/shared/sampletabbar/presentation/ui/ComposeViewFactory.kt b/shared/sampletabbar/src/androidMain/kotlin/kmp/shared/sampletabbar/presentation/ui/ComposeViewFactory.kt new file mode 100644 index 00000000..e5f983a1 --- /dev/null +++ b/shared/sampletabbar/src/androidMain/kotlin/kmp/shared/sampletabbar/presentation/ui/ComposeViewFactory.kt @@ -0,0 +1,4 @@ +package kmp.shared.sampletabbar.presentation.ui + +// Originally generated by Touchlab's [compose-swift-bridge] +actual interface ComposeViewFactory diff --git a/shared/sampletabbar/src/androidMain/kotlin/kmp/shared/sampletabbar/presentation/ui/NativeScaffold.android.kt b/shared/sampletabbar/src/androidMain/kotlin/kmp/shared/sampletabbar/presentation/ui/NativeScaffold.android.kt new file mode 100644 index 00000000..6f6e1e1e --- /dev/null +++ b/shared/sampletabbar/src/androidMain/kotlin/kmp/shared/sampletabbar/presentation/ui/NativeScaffold.android.kt @@ -0,0 +1,116 @@ +package kmp.shared.sampletabbar.presentation.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +actual fun NativeScaffold( + modifier: Modifier, + toolbar: Toolbar?, + tabs: List, + selectedTabPosition: Int, + onTabSelected: (Int) -> Unit, + content: @Composable (currentTabPosition: Int?, contentPadding: PaddingValues) -> Unit, +) { + val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() + Scaffold( + modifier = modifier, + topBar = { + toolbar?.let { + CenterAlignedTopAppBar( + title = { + toolbar.title?.let { title -> + Text(title) + } + }, + scrollBehavior = scrollBehavior, + colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = Color.Transparent, + scrolledContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f), + ), + navigationIcon = { + toolbar.buttons + .filter { it.position == ToolbarButtonPosition.Leading } + .forEach { button -> + IconButton(button.onClick) { + Icon( + painter = painterResource(button.icon.drawableResId), + contentDescription = button.description, + tint = button.tint?.composeColor + ?: LocalContentColor.current, + ) + } + } + }, + actions = { + toolbar.buttons + .filter { it.position == ToolbarButtonPosition.Trailing } + .forEach { button -> + IconButton(button.onClick) { + Icon( + painter = painterResource(button.icon.drawableResId), + contentDescription = button.description, + tint = button.tint?.composeColor + ?: LocalContentColor.current, + ) + } + } + }, + modifier = Modifier, + ) + } + }, + bottomBar = { + AnimatedVisibility(visible = tabs.isNotEmpty()) { + NavigationBar( + modifier = Modifier.fillMaxWidth(), + ) { + tabs.forEach { tab -> + NavigationBarItem( + selected = selectedTabPosition == tab.position, + icon = { + Icon( + painter = painterResource(tab.icon.drawableResId), + contentDescription = tab.title, + ) + }, + onClick = { onTabSelected(tab.position) }, + label = { + Text(tab.title) + }, + modifier = Modifier.padding( + bottom = WindowInsets.systemBars.asPaddingValues() + .calculateBottomPadding(), + ), + ) + } + } + } + }, + ) { contentPadding -> + content(selectedTabPosition.takeIf { tabs.isNotEmpty() }, contentPadding) + } +} + +actual class NativeColor actual constructor(val composeColor: Color) diff --git a/shared/sampletabbar/src/commonMain/kotlin/kmp/shared/sampletabbar/di/Module.kt b/shared/sampletabbar/src/commonMain/kotlin/kmp/shared/sampletabbar/di/Module.kt new file mode 100644 index 00000000..78c2bc56 --- /dev/null +++ b/shared/sampletabbar/src/commonMain/kotlin/kmp/shared/sampletabbar/di/Module.kt @@ -0,0 +1,9 @@ +package kmp.shared.sampletabbar.di + +import kmp.shared.sampletabbar.presentation.vm.SampleTabBarViewModel +import org.koin.core.module.dsl.viewModelOf +import org.koin.dsl.module + +val sampleTabBarModule = module { + viewModelOf(::SampleTabBarViewModel) +} diff --git a/shared/sampletabbar/src/commonMain/kotlin/kmp/shared/sampletabbar/presentation/ui/BlurredContainer.kt b/shared/sampletabbar/src/commonMain/kotlin/kmp/shared/sampletabbar/presentation/ui/BlurredContainer.kt new file mode 100644 index 00000000..692edc65 --- /dev/null +++ b/shared/sampletabbar/src/commonMain/kotlin/kmp/shared/sampletabbar/presentation/ui/BlurredContainer.kt @@ -0,0 +1,87 @@ +package kmp.shared.sampletabbar.presentation.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.LinearEasing +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import dev.chrisbanes.haze.HazeProgressive +import dev.chrisbanes.haze.hazeEffect +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import dev.chrisbanes.haze.materials.HazeMaterials +import dev.chrisbanes.haze.rememberHazeState + +@OptIn(ExperimentalHazeMaterialsApi::class) +@Composable +fun BlurredContainer( + top: Dp = 0.dp, + bottom: Dp = 0.dp, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + val hazeState = rememberHazeState() + + Box(modifier = modifier.fillMaxSize()) { + Box(modifier = Modifier.hazeSource(hazeState)) { + content() + } + + Column { + val backgroundColor = MaterialTheme.colorScheme.background + AnimatedVisibility(visible = top > 0.dp) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(top) + .hazeEffect( + hazeState, + style = HazeMaterials.ultraThin(containerColor = Color.White), + ) { + progressive = HazeProgressive.verticalGradient( + easing = LinearEasing, + startIntensity = 0.3f, + endIntensity = 0f, + ) + } + .background( + Brush.verticalGradient( + 0f to backgroundColor.copy(alpha = 0.85f), + 0.8f to backgroundColor.copy(alpha = 0.75f), + 1f to Color.Transparent, + ), + ), + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + AnimatedVisibility(visible = bottom > 0.dp) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(bottom) + .background( + Brush.verticalGradient( + listOf( + Color.Transparent, + backgroundColor.copy(alpha = 0.7f), + ), + ), + ), + ) + } + } + } +} diff --git a/shared/sampletabbar/src/commonMain/kotlin/kmp/shared/sampletabbar/presentation/ui/ComposeViewFactory.kt b/shared/sampletabbar/src/commonMain/kotlin/kmp/shared/sampletabbar/presentation/ui/ComposeViewFactory.kt new file mode 100644 index 00000000..dd96f3bf --- /dev/null +++ b/shared/sampletabbar/src/commonMain/kotlin/kmp/shared/sampletabbar/presentation/ui/ComposeViewFactory.kt @@ -0,0 +1,7 @@ +package kmp.shared.sampletabbar.presentation.ui + +// Originally generated by Touchlab's [compose-swift-bridge] +typealias ViewFactory = + ComposeViewFactory + +expect interface ComposeViewFactory diff --git a/shared/sampletabbar/src/commonMain/kotlin/kmp/shared/sampletabbar/presentation/ui/LocalViewFactory.kt b/shared/sampletabbar/src/commonMain/kotlin/kmp/shared/sampletabbar/presentation/ui/LocalViewFactory.kt new file mode 100644 index 00000000..70ecc0fa --- /dev/null +++ b/shared/sampletabbar/src/commonMain/kotlin/kmp/shared/sampletabbar/presentation/ui/LocalViewFactory.kt @@ -0,0 +1,14 @@ +package kmp.shared.sampletabbar.presentation.ui + +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.compositionLocalOf + +// Originally generated by Touchlab's [compose-swift-bridge] +val LocalViewFactory: ProvidableCompositionLocal = + compositionLocalOf( + defaultFactory = { + error( + """You have to provide LocalViewFactory""", + ) + }, + ) diff --git a/shared/sampletabbar/src/commonMain/kotlin/kmp/shared/sampletabbar/presentation/ui/NativeScaffold.kt b/shared/sampletabbar/src/commonMain/kotlin/kmp/shared/sampletabbar/presentation/ui/NativeScaffold.kt new file mode 100644 index 00000000..10747fbd --- /dev/null +++ b/shared/sampletabbar/src/commonMain/kotlin/kmp/shared/sampletabbar/presentation/ui/NativeScaffold.kt @@ -0,0 +1,45 @@ +package kmp.shared.sampletabbar.presentation.ui + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import dev.icerock.moko.resources.ImageResource + +@Composable +expect fun NativeScaffold( + modifier: Modifier = Modifier, + toolbar: Toolbar? = null, + tabs: List = emptyList(), + selectedTabPosition: Int = 0, + onTabSelected: (Int) -> Unit = {}, + content: @Composable (currentTabPosition: Int?, contentPadding: PaddingValues) -> Unit, +) + +data class Toolbar( + val title: String? = null, + val buttons: List = emptyList(), +) + +data class ToolbarButtonData( + val icon: ImageResource, + val description: String? = null, + val position: ToolbarButtonPosition = ToolbarButtonPosition.Trailing, + val tint: NativeColor? = null, + val onClick: () -> Unit, +) + +expect class NativeColor { + constructor(composeColor: Color) +} + +enum class ToolbarButtonPosition { + Leading, + Trailing, +} + +data class TabItem( + val title: String, + val icon: ImageResource, + val position: Int, +) diff --git a/shared/sampletabbar/src/commonMain/kotlin/kmp/shared/sampletabbar/presentation/ui/SampleTabBarScreen.kt b/shared/sampletabbar/src/commonMain/kotlin/kmp/shared/sampletabbar/presentation/ui/SampleTabBarScreen.kt new file mode 100644 index 00000000..15458d92 --- /dev/null +++ b/shared/sampletabbar/src/commonMain/kotlin/kmp/shared/sampletabbar/presentation/ui/SampleTabBarScreen.kt @@ -0,0 +1,149 @@ +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import dev.icerock.moko.resources.ImageResource +import kmp.shared.base.MR +import kmp.shared.sampletabbar.presentation.ui.NativeColor +import kmp.shared.sampletabbar.presentation.ui.NativeScaffold +import kmp.shared.sampletabbar.presentation.ui.TabItem +import kmp.shared.sampletabbar.presentation.ui.Toolbar +import kmp.shared.sampletabbar.presentation.ui.ToolbarButtonData +import kmp.shared.sampletabbar.presentation.ui.ToolbarButtonPosition +import kmp.shared.sampletabbar.presentation.vm.SampleTab +import kmp.shared.sampletabbar.presentation.vm.SampleTabBarIntent +import kmp.shared.sampletabbar.presentation.vm.SampleTabBarState + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SampleTabBarScreen( + state: SampleTabBarState, + onIntent: (SampleTabBarIntent) -> Unit, + modifier: Modifier = Modifier, +) { + NativeScaffold( + toolbar = Toolbar( + title = SampleTab.entries[state.selectedTabPosition].title, + buttons = listOf( + ToolbarButtonData( + icon = MR.images.search, + description = "Search", + onClick = { println("XXX Search clicked") }, + position = ToolbarButtonPosition.Leading, + tint = NativeColor(Color.Red), + ), + ToolbarButtonData( + icon = MR.images.person, + description = "Person", + onClick = { println("XXX Person clicked") }, + position = ToolbarButtonPosition.Trailing, + ), + ToolbarButtonData( + icon = MR.images.home, + description = "Home", + onClick = { println("XXX Home clicked") }, + position = ToolbarButtonPosition.Trailing, + tint = NativeColor(MaterialTheme.colorScheme.primary), + ), + ), + ), + tabs = state.tabs.map { tab -> + TabItem( + position = tab.ordinal, + title = tab.title, + icon = tab.icon, + ) + }, + selectedTabPosition = state.selectedTabPosition, + onTabSelected = { index -> + onIntent(SampleTabBarIntent.OnTabSelected(index)) + }, + modifier = modifier.fillMaxSize(), + ) { position, innerPadding -> + if (position != null) { + val tab = SampleTab.entries[position] + when (tab) { + SampleTab.Numbers -> ScrollableContent(contentPadding = innerPadding) { index -> + Text( + text = index.toString(), + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + } + + SampleTab.Sentences -> ScrollableContent(contentPadding = innerPadding) { index -> + Text( + text = "This is item number $index", + style = MaterialTheme.typography.headlineMedium, + ) + Text( + text = "And this is it's body", + style = MaterialTheme.typography.bodySmall, + ) + } + + SampleTab.Images -> ScrollableContent(contentPadding = innerPadding) { index -> + AsyncImage( + model = "https://picsum.photos/id/$index/200/300", + contentDescription = null, + modifier = Modifier.fillMaxWidth(), + contentScale = ContentScale.FillWidth, + ) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) +@Composable +private fun ScrollableContent( + contentPadding: PaddingValues = PaddingValues(), + modifier: Modifier = Modifier, + itemCount: Int = 20, + itemContent: @Composable ColumnScope.(index: Int) -> Unit, +) { + LazyColumn( + modifier = modifier, + contentPadding = contentPadding, + ) { + items(count = itemCount) { index -> + Card( + modifier = Modifier.fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + ) { + Column(modifier = Modifier.padding(16.dp)) { + itemContent(index) + } + } + } + } +} + +private val SampleTab.title: String + get() = when (this) { + SampleTab.Numbers -> "Numbers" + SampleTab.Sentences -> "Sentences" + SampleTab.Images -> "Images" + } + +private val SampleTab.icon: ImageResource + get() = when (this) { + SampleTab.Numbers -> MR.images.home + SampleTab.Sentences -> MR.images.search + SampleTab.Images -> MR.images.person + } diff --git a/shared/sampletabbar/src/commonMain/kotlin/kmp/shared/sampletabbar/presentation/ui/SampleTheme.kt b/shared/sampletabbar/src/commonMain/kotlin/kmp/shared/sampletabbar/presentation/ui/SampleTheme.kt new file mode 100644 index 00000000..2584b260 --- /dev/null +++ b/shared/sampletabbar/src/commonMain/kotlin/kmp/shared/sampletabbar/presentation/ui/SampleTheme.kt @@ -0,0 +1,97 @@ +package kmp.shared.sampletabbar.presentation.ui + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes +import androidx.compose.material3.Typography +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import kmp.shared.samplecomposemultiplatform.presentation.common.Values + +// https://coolors.co/f5ab00-b8a422-9aa133-7b9d44-d95700-e0e0e0-f0f0f0 +val LightColorScheme = lightColorScheme( + primary = Color(0xFFF5AB00), + onPrimary = Color(0xFFFFFFFF), + primaryContainer = Color(0xFFB8A422), + onPrimaryContainer = Color(0xFFFFFFFF), + + secondary = Color(0xFF7B9D44), + onSecondary = Color(0xFF000000), + secondaryContainer = Color(0xFF9AA133), + onSecondaryContainer = Color(0xFF000000), + + background = Color(0xFFF0F0F0), + onBackground = Color(0xFF000000), + + surface = Color(0xFFE0E0E0), + onSurface = Color(0xFF000000), + + error = Color(0xFFD95700), + onError = Color(0xFF000000), + + // Optional M3 roles — fill with best approximations: + surfaceVariant = Color(0xFFE0E0E0), + onSurfaceVariant = Color(0xFF000000), + outline = Color(0xFF9AA133), + outlineVariant = Color(0xFFB8A422), + scrim = Color(0xFF000000), + inverseSurface = Color(0xFF000000), + inverseOnSurface = Color(0xFFE0E0E0), + inversePrimary = Color(0xFFB8A422), +) + +// https://coolors.co/f5ab00-b8a422-9aa133-7b9d44-d95700-1f1f1f-141414 +val DarkColorScheme = darkColorScheme( + primary = Color(0xFFF5AB00), + onPrimary = Color(0xFFFFFFFF), + primaryContainer = Color(0xFFB8A422), + onPrimaryContainer = Color(0xFFFFFFFF), + + secondary = Color(0xFF7B9D44), + onSecondary = Color(0xFF000000), + secondaryContainer = Color(0xFF9AA133), + onSecondaryContainer = Color(0xFF000000), + + background = Color(0xFF141414), + onBackground = Color(0xFFFFFFFF), + + surface = Color(0xFF1F1F1F), + onSurface = Color(0xFFFFFFFF), + + error = Color(0xFFD95700), + onError = Color(0xFFFFFFFF), + + // Optional roles + surfaceVariant = Color(0xFF1F1F1F), + onSurfaceVariant = Color(0xFFFFFFFF), + outline = Color(0xFF9AA133), + outlineVariant = Color(0xFFB8A422), + scrim = Color(0xFF000000), + inverseSurface = Color(0xFFE0E0E0), + inverseOnSurface = Color(0xFF000000), + inversePrimary = Color(0xFFB8A422), +) + +val typography = Typography( + // Define typohraphy +) + +val shapes = Shapes( + small = RoundedCornerShape(Values.Radius.large), + medium = RoundedCornerShape(Values.Radius.medium), + large = RoundedCornerShape(Values.Radius.small), +) + +@Composable +fun SampleTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { + val colors = if (darkTheme) DarkColorScheme else LightColorScheme + MaterialTheme( + colorScheme = colors, + typography = typography, + shapes = shapes, + content = content, + ) +} diff --git a/shared/sampletabbar/src/commonMain/kotlin/kmp/shared/sampletabbar/presentation/vm/SampleTabBarViewModel.kt b/shared/sampletabbar/src/commonMain/kotlin/kmp/shared/sampletabbar/presentation/vm/SampleTabBarViewModel.kt new file mode 100644 index 00000000..77aae1f9 --- /dev/null +++ b/shared/sampletabbar/src/commonMain/kotlin/kmp/shared/sampletabbar/presentation/vm/SampleTabBarViewModel.kt @@ -0,0 +1,43 @@ +package kmp.shared.sampletabbar.presentation.vm + +import kmp.shared.samplesharedviewmodel.base.vm.BaseViewModel +import kmp.shared.samplesharedviewmodel.base.vm.VmEvent +import kmp.shared.samplesharedviewmodel.base.vm.VmIntent +import kmp.shared.samplesharedviewmodel.base.vm.VmState + +class SampleTabBarViewModel : + BaseViewModel(SampleTabBarState()) { + + override suspend fun applyIntent(intent: SampleTabBarIntent) { + when (intent) { + SampleTabBarIntent.OnAppeared -> onAppeared() + is SampleTabBarIntent.OnTabSelected -> update { copy(selectedTabPosition = intent.index) } + } + } + + private suspend fun onAppeared() { + if (state.value.tabs.isEmpty()) { + update { + copy( + tabs = SampleTab.entries, + ) + } + } + } +} + +data class SampleTabBarState( + val tabs: List = emptyList(), + val selectedTabPosition: Int = 0, +) : VmState + +sealed interface SampleTabBarIntent : VmIntent { + data object OnAppeared : SampleTabBarIntent + data class OnTabSelected(val index: Int) : SampleTabBarIntent +} + +sealed interface SampleTabBarEvent : VmEvent + +enum class SampleTab { + Numbers, Sentences, Images, +} diff --git a/shared/sampletabbar/src/iosMain/kotlin/kmp/shared/sampletabbar/presentation/SampleTabBarViewController.kt b/shared/sampletabbar/src/iosMain/kotlin/kmp/shared/sampletabbar/presentation/SampleTabBarViewController.kt new file mode 100644 index 00000000..5fd23680 --- /dev/null +++ b/shared/sampletabbar/src/iosMain/kotlin/kmp/shared/sampletabbar/presentation/SampleTabBarViewController.kt @@ -0,0 +1,47 @@ +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.window.ComposeUIViewController +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kmp.shared.sampletabbar.presentation.ui.LocalViewFactory +import kmp.shared.sampletabbar.presentation.ui.SampleTheme +import kmp.shared.sampletabbar.presentation.ui.ViewFactory +import kmp.shared.sampletabbar.presentation.vm.SampleTabBarEvent +import kmp.shared.sampletabbar.presentation.vm.SampleTabBarIntent +import kmp.shared.sampletabbar.presentation.vm.SampleTabBarViewModel +import kotlinx.coroutines.flow.collectLatest +import org.koin.compose.viewmodel.koinViewModel +import platform.UIKit.UIViewController + +@Suppress("Unused", "FunctionName") +fun SampleTabBarViewController( + onEvent: (SampleTabBarEvent) -> Unit, + factory: ViewFactory, +): UIViewController { + return ComposeUIViewController { + val viewModel: SampleTabBarViewModel = koinViewModel() + val state by viewModel.state.collectAsStateWithLifecycle() + + LaunchedEffect(viewModel) { + viewModel.onIntent(SampleTabBarIntent.OnAppeared) + } + + LaunchedEffect(viewModel) { + viewModel.events.collectLatest { event -> + onEvent(event) + } + } + CompositionLocalProvider( + LocalViewFactory provides factory, + ) { + SampleTheme { + SampleTabBarScreen( + state = state, + onIntent = viewModel::onIntent, + modifier = Modifier, + ) + } + } + } +} diff --git a/shared/sampletabbar/src/iosMain/kotlin/kmp/shared/sampletabbar/presentation/ui/ComposeViewFactory.kt b/shared/sampletabbar/src/iosMain/kotlin/kmp/shared/sampletabbar/presentation/ui/ComposeViewFactory.kt new file mode 100644 index 00000000..9769b160 --- /dev/null +++ b/shared/sampletabbar/src/iosMain/kotlin/kmp/shared/sampletabbar/presentation/ui/ComposeViewFactory.kt @@ -0,0 +1,15 @@ +package kmp.shared.sampletabbar.presentation.ui + +import platform.UIKit.UIViewController + +// Originally generated by Touchlab's [compose-swift-bridge] +actual interface ComposeViewFactory { + + fun createNativeScaffold( + toolbar: Toolbar?, + tabs: List, + selectedTabPosition: Int, + onTabSelected: (Int) -> Unit, + content: (Int?) -> UIViewController, + ): Pair +} diff --git a/shared/sampletabbar/src/iosMain/kotlin/kmp/shared/sampletabbar/presentation/ui/NativeScaffold.ios.kt b/shared/sampletabbar/src/iosMain/kotlin/kmp/shared/sampletabbar/presentation/ui/NativeScaffold.ios.kt new file mode 100644 index 00000000..6c70ba14 --- /dev/null +++ b/shared/sampletabbar/src/iosMain/kotlin/kmp/shared/sampletabbar/presentation/ui/NativeScaffold.ios.kt @@ -0,0 +1,98 @@ +package kmp.shared.sampletabbar.presentation.ui + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.safeContent +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.viewinterop.UIKitViewController +import androidx.compose.ui.window.ComposeUIViewController +import androidx.lifecycle.viewmodel.compose.viewModel +import kmp.shared.samplecomposemultiplatform.presentation.ui.NativeViewHolderViewModel +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.useContents +import platform.Foundation.NSProcessInfo +import platform.UIKit.UIColor +import platform.UIKit.UIViewController +import kotlin.random.Random + +@OptIn(ExperimentalForeignApi::class, ExperimentalComposeUiApi::class) +@Composable +actual fun NativeScaffold( + modifier: Modifier, + toolbar: Toolbar?, + tabs: List, + selectedTabPosition: Int, + onTabSelected: (Int) -> Unit, + content: @Composable (currentTabPosition: Int?, contentPadding: PaddingValues) -> Unit, +) { + val factory = LocalViewFactory.current + val key = rememberSaveable { Random.nextInt().toString(16) } + + fun contentMapper(position: Int?): UIViewController = + ComposeUIViewController { + CompositionLocalProvider( + LocalViewFactory provides factory, + ) { + SampleTheme { + val showBlur = NSProcessInfo.processInfo.operatingSystemVersion.useContents { + this.majorVersion >= 26 + } + if (showBlur) { + BlurredContainer( + top = WindowInsets.safeContent.asPaddingValues().calculateTopPadding(), + bottom = WindowInsets.safeContent.asPaddingValues() + .calculateBottomPadding(), + modifier = Modifier.fillMaxSize(), + ) { + content(position, WindowInsets.safeContent.asPaddingValues()) + } + } else { + content(position, WindowInsets.safeContent.asPaddingValues()) + } + } + } + } + + val viewModel = viewModel(key = key) { + NativeViewHolderViewModel { + factory.createNativeScaffold( + toolbar = toolbar, + tabs = tabs, + selectedTabPosition = selectedTabPosition, + onTabSelected = onTabSelected, + content = ::contentMapper, + ) + } + } + val delegate = remember(viewModel) { viewModel.delegate } + val view = remember(viewModel) { viewModel.view } + + remember(toolbar) { delegate.updateToolbar(toolbar) } + remember(tabs) { delegate.updateTabs(tabs) } + remember(selectedTabPosition) { delegate.updateSelectedTab(selectedTabPosition) } + + UIKitViewController( + modifier = modifier, + factory = { view }, + update = { _ -> }, + ) +} + +actual class NativeColor actual constructor(private val composeColor: Color) { + + fun toUIColor(): UIColor = + UIColor.colorWithRed( + red = composeColor.red.toDouble(), + green = composeColor.green.toDouble(), + blue = composeColor.blue.toDouble(), + alpha = composeColor.alpha.toDouble(), + ) +} diff --git a/shared/sampletabbar/src/iosMain/kotlin/kmp/shared/sampletabbar/presentation/ui/NativeScaffoldDelegate.kt b/shared/sampletabbar/src/iosMain/kotlin/kmp/shared/sampletabbar/presentation/ui/NativeScaffoldDelegate.kt new file mode 100644 index 00000000..1086ba95 --- /dev/null +++ b/shared/sampletabbar/src/iosMain/kotlin/kmp/shared/sampletabbar/presentation/ui/NativeScaffoldDelegate.kt @@ -0,0 +1,7 @@ +package kmp.shared.sampletabbar.presentation.ui + +interface NativeScaffoldDelegate { + fun updateToolbar(toolbar: Toolbar?) + fun updateTabs(tabs: List) + fun updateSelectedTab(selectedTabPosition: Int) +}