From 18d004ffe0232e7c6028d2cd11039136e7dc59de Mon Sep 17 00:00:00 2001 From: WhiteGiso Date: Thu, 18 Jun 2026 14:59:22 +0200 Subject: [PATCH] Add native liquid glass profile tab handling --- .../app/core/ui/NativeTabBridge.android.kt | 2 - .../commonMain/kotlin/com/nuvio/app/App.kt | 10 +- .../com/nuvio/app/core/ui/NativeTabBridge.kt | 2 - .../features/profiles/ProfileSwitcherTab.kt | 15 +- .../nuvio/app/core/ui/NativeTabBridge.ios.kt | 8 - iosApp/iosApp/ContentView.swift | 188 ++++++++++-------- 6 files changed, 116 insertions(+), 109 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.android.kt index 900c3489b..8e860fa35 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.android.kt @@ -23,5 +23,3 @@ internal actual fun publishNativeProfileTabIcon( avatarImageUrl: String?, avatarBackgroundColorHex: String?, ) = Unit - -internal actual fun notifyNativeProfileSwitcherPopupDismissed() = Unit diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 755ffd9cd..21b046131 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -103,6 +103,7 @@ import com.nuvio.app.core.ui.NativeTabBridge import com.nuvio.app.core.ui.isLiquidGlassNativeTabBarSupported import com.nuvio.app.core.ui.localizedContinueWatchingSubtitle import com.nuvio.app.core.ui.nuvio +import com.nuvio.app.core.ui.nuvioBottomNavigationBarInsets import com.nuvio.app.features.auth.AuthScreen import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.catalog.CatalogRepository @@ -1422,6 +1423,11 @@ private fun MainAppContent( val isTabletLayout = maxWidth >= 768.dp val useNativeBottomTabs = liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled && initialHomeReady + val nativeTabSafeBottomPadding = nuvioBottomNavigationBarInsets() + .asPaddingValues() + .calculateBottomPadding() + val nativeProfileTabAnchorBottomPadding = + nativeTabSafeBottomPadding + NuvioTokens.Space.s10 val tabsRouteActive = currentBackStackEntry?.destination?.hasRoute() == true val onProfileSelected: (NuvioProfile) -> Unit = { profile -> nativeProfileSwitcherVisible = false @@ -1596,7 +1602,9 @@ private fun MainAppContent( nativeProfileSwitcherVisible = false onSwitchProfile() }, - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .padding(bottom = nativeProfileTabAnchorBottomPadding), ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.kt index 18299c1fa..2b9d48160 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.kt @@ -102,5 +102,3 @@ internal expect fun publishNativeProfileTabIcon( avatarImageUrl: String?, avatarBackgroundColorHex: String?, ) - -internal expect fun notifyNativeProfileSwitcherPopupDismissed() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt index ba179788b..5e9b47952 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt @@ -72,7 +72,6 @@ import coil3.compose.AsyncImage import com.nuvio.app.core.ui.NuvioTokens import com.nuvio.app.core.ui.nuvio import com.nuvio.app.isIos -import com.nuvio.app.core.ui.notifyNativeProfileSwitcherPopupDismissed import kotlinx.coroutines.delay import kotlinx.coroutines.launch import nuvio.composeapp.generated.resources.* @@ -352,6 +351,7 @@ fun NativeProfileSwitcherPopup( val activeProfile = profileState.activeProfile val profiles = profileState.profiles val avatars by AvatarRepository.avatars.collectAsStateWithLifecycle() + val tokens = MaterialTheme.nuvio val haptic = LocalHapticFeedback.current val density = LocalDensity.current @@ -415,7 +415,6 @@ fun NativeProfileSwitcherPopup( ) } } else if (popupVisible) { - notifyNativeProfileSwitcherPopupDismissed() launch { popupAlpha.animateTo(0f, tween(180, easing = FastOutSlowInEasing)) } launch { popupScale.animateTo(0.85f, tween(200, easing = FastOutSlowInEasing)) } launch { @@ -437,7 +436,7 @@ fun NativeProfileSwitcherPopup( if (popupVisible && profiles.isNotEmpty() && !isSwitchingProfile) { Popup( alignment = Alignment.BottomCenter, - offset = IntOffset(0, with(density) { -84.dp.roundToPx() }), + offset = IntOffset(0, with(density) { -NuvioTokens.Space.s64.roundToPx() }), properties = PopupProperties(focusable = true), onDismissRequest = onDismissRequest, ) { @@ -450,16 +449,16 @@ fun NativeProfileSwitcherPopup( scaleY = popupScale.value translationY = popupTranslateY.value } - .shadow(16.dp, RoundedCornerShape(28.dp)) + .shadow(tokens.elevation.overlay, tokens.shapes.sheet) .background( - MaterialTheme.colorScheme.surfaceContainerHigh, - RoundedCornerShape(28.dp), + tokens.colors.surfaceSheet, + tokens.shapes.sheet, ) - .padding(16.dp), + .padding(tokens.spacing.sheetPadding), ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), + horizontalArrangement = Arrangement.spacedBy(tokens.spacing.cardPadding), verticalAlignment = Alignment.Top, ) { profiles.forEachIndexed { index, profile -> diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.ios.kt index 5fad29aab..6a3c7e7f6 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.ios.kt @@ -17,7 +17,6 @@ private const val nativeProfileNameKey = "NuvioNativeProfileName" private const val nativeProfileAvatarColorKey = "NuvioNativeProfileAvatarColor" private const val nativeProfileAvatarUrlKey = "NuvioNativeProfileAvatarURL" private const val nativeProfileAvatarBackgroundColorKey = "NuvioNativeProfileAvatarBackgroundColor" -private const val nativeProfileSwitcherPopupDismissedNotification = "NuvioNativeProfileSwitcherPopupDismissed" private const val nativeTabChromeDidChangeNotification = "NuvioNativeTabChromeDidChange" internal actual fun isLiquidGlassNativeTabBarSupported(): Boolean { @@ -69,13 +68,6 @@ internal actual fun publishNativeProfileTabIcon( notifyNativeTabChromeChanged() } -internal actual fun notifyNativeProfileSwitcherPopupDismissed() { - NSNotificationCenter.defaultCenter.postNotificationName( - nativeProfileSwitcherPopupDismissedNotification, - null, - ) -} - private fun publishBool(key: String, value: Boolean) { NSUserDefaults.standardUserDefaults.setBool(value, forKey = key) notifyNativeTabChromeChanged() diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index ac5db8145..c8fb0338b 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -253,7 +253,7 @@ private enum NuvioNativeTabIcon { } } -final class RootComposeViewController: UIViewController, UITabBarDelegate, UIGestureRecognizerDelegate { +final class RootComposeViewController: UIViewController, UITabBarDelegate { private enum NativeTab: String, CaseIterable { case home = "Home" case search = "Search" @@ -314,18 +314,17 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate, UIGes private static let nativeProfileAvatarColorKey = "NuvioNativeProfileAvatarColor" private static let nativeProfileAvatarURLKey = "NuvioNativeProfileAvatarURL" private static let nativeProfileAvatarBackgroundColorKey = "NuvioNativeProfileAvatarBackgroundColor" - private static let nativeProfileSwitcherPopupDismissedNotification = Notification.Name("NuvioNativeProfileSwitcherPopupDismissed") private static let nativeTabChromeDidChangeNotification = Notification.Name("NuvioNativeTabChromeDidChange") private let contentController: UIViewController private let tabBar = UITabBar() + private let profileTabTouchOverlay = UIControl() private var contentBottomToViewBottom: NSLayoutConstraint? private var tabBarHeightConstraint: NSLayoutConstraint? private var userDefaultsObserver: NSObjectProtocol? private var tabChromeObserver: NSObjectProtocol? - private var profileSwitcherPopupObserver: NSObjectProtocol? - private var profileLongPressRecognizer: UILongPressGestureRecognizer? - private var suppressNextProfileSelection = false + private var profileTouchRestoreTab: NativeTab? + private var profileLongPressHandled = false private var profileAvatarImageURL: String? private var profileAvatarImageTask: URLSessionDataTask? private var profileAvatarImage: UIImage? @@ -372,9 +371,6 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate, UIGes if let tabChromeObserver { NotificationCenter.default.removeObserver(tabChromeObserver) } - if let profileSwitcherPopupObserver { - NotificationCenter.default.removeObserver(profileSwitcherPopupObserver) - } profileAvatarImageTask?.cancel() } @@ -383,30 +379,16 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate, UIGes updateTabBarHeight() } + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + updateProfileTabTouchOverlayFrame() + } + func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) { guard let tab = NativeTab(tag: item.tag) else { return } - if tab == .settings && suppressNextProfileSelection { - suppressNextProfileSelection = false - restoreNativeTabFocus(to: currentNativeSelectedTab) - return - } selectNativeTab(tab) } - func gestureRecognizer( - _ gestureRecognizer: UIGestureRecognizer, - shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer - ) -> Bool { - gestureRecognizer === profileLongPressRecognizer - } - - func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { - if gestureRecognizer === profileLongPressRecognizer { - return nativeTab(at: touch.location(in: tabBar)) == .settings - } - return true - } - override var childForHomeIndicatorAutoHidden: UIViewController? { immersiveController(in: contentController) ?? contentController } @@ -486,18 +468,13 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate, UIGes item.tag = tab.tag return item } - let longPressRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(handleNativeProfileTabLongPress(_:))) - longPressRecognizer.delegate = self - longPressRecognizer.minimumPressDuration = 0.45 - longPressRecognizer.cancelsTouchesInView = true - tabBar.addGestureRecognizer(longPressRecognizer) - profileLongPressRecognizer = longPressRecognizer tabBar.selectedItem = tabBar.items?.first applyNativeTabBarAppearance() tabBar.alpha = 0 tabBar.isHidden = true view.addSubview(tabBar) + configureProfileTabTouchOverlay() let heightConstraint = tabBar.heightAnchor.constraint(equalToConstant: tabBarHeight) tabBarHeightConstraint = heightConstraint NSLayoutConstraint.activate([ @@ -524,14 +501,6 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate, UIGes ) { [weak self] _ in self?.syncNativeTabChrome(animated: true) } - - profileSwitcherPopupObserver = NotificationCenter.default.addObserver( - forName: Self.nativeProfileSwitcherPopupDismissedNotification, - object: nil, - queue: .main - ) { [weak self] _ in - self?.suppressNextProfileSelection = false - } } private var tabBarHeight: CGFloat { @@ -540,6 +509,7 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate, UIGes private func updateTabBarHeight() { tabBarHeightConstraint?.constant = tabBarHeight + updateProfileTabTouchOverlayFrame() } private func syncNativeTabChrome(animated: Bool) { @@ -551,15 +521,18 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate, UIGes contentBottomToViewBottom?.isActive = true if visible { tabBar.isHidden = false + profileTabTouchOverlay.isHidden = false } let changes = { self.tabBar.alpha = visible ? 1 : 0 + self.profileTabTouchOverlay.alpha = visible ? 1 : 0 self.view.layoutIfNeeded() } let completion: (Bool) -> Void = { _ in self.tabBar.isHidden = !visible + self.profileTabTouchOverlay.isHidden = !visible } if animated && view.window != nil { @@ -583,13 +556,33 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate, UIGes @objc private func handleNativeProfileTabLongPress(_ recognizer: UILongPressGestureRecognizer) { guard recognizer.state == .began else { return } - suppressNextProfileSelection = true - let tabToRestore = currentNativeSelectedTab - cancelNativeTabTracking() - DispatchQueue.main.async { [weak self] in - self?.restoreNativeTabFocus(to: tabToRestore) + profileLongPressHandled = true + DispatchQueue.main.async { NativeTabBridgeKt.nativeProfileTabLongPress() } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) { [weak self] in + self?.restoreProfileTabTouchIfNeeded() + } + } + + @objc private func handleNativeProfileTabTouchDown() { + profileTouchRestoreTab = currentNativeSelectedTab + profileLongPressHandled = false + } + + @objc private func handleNativeProfileTabTap() { + if profileLongPressHandled { + profileLongPressHandled = false + restoreProfileTabTouchIfNeeded() + return + } + profileTouchRestoreTab = nil + selectNativeTab(.settings) + } + + @objc private func handleNativeProfileTabTouchCancel() { + profileLongPressHandled = false + restoreProfileTabTouchIfNeeded() } private var currentNativeSelectedTab: NativeTab { @@ -597,50 +590,78 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate, UIGes return NativeTab(rawValue: rawValue) ?? .home } - private func nativeTab(at point: CGPoint) -> NativeTab? { - guard tabBar.bounds.contains(point), tabBar.bounds.width > 0 else { return nil } - let tabs = NativeTab.allCases - let rawIndex = Int((point.x / tabBar.bounds.width) * CGFloat(tabs.count)) - let clampedIndex = min(max(rawIndex, 0), tabs.count - 1) - return tabs[clampedIndex] - } - private func selectNativeTab(_ tab: NativeTab) { tabBar.selectedItem = tabBar.items?.first(where: { $0.tag == tab.tag }) UserDefaults.standard.set(tab.rawValue, forKey: Self.nativeSelectedTabKey) NativeTabBridgeKt.nativeTabSelect(tabName: tab.rawValue) } - private func restoreNativeTabFocus(to tab: NativeTab) { - guard let item = tabBar.items?.first(where: { $0.tag == tab.tag }) else { return } - UserDefaults.standard.set(tab.rawValue, forKey: Self.nativeSelectedTabKey) - NativeTabBridgeKt.nativeTabSelect(tabName: tab.rawValue) + private func configureProfileTabTouchOverlay() { + profileTabTouchOverlay.backgroundColor = .clear + profileTabTouchOverlay.isOpaque = false + profileTabTouchOverlay.isExclusiveTouch = true + profileTabTouchOverlay.accessibilityLabel = NativeTab.settings.localizedTitle() + profileTabTouchOverlay.accessibilityTraits = .button + profileTabTouchOverlay.addTarget( + self, + action: #selector(handleNativeProfileTabTouchDown), + for: .touchDown + ) + profileTabTouchOverlay.addTarget( + self, + action: #selector(handleNativeProfileTabTap), + for: .touchUpInside + ) + profileTabTouchOverlay.addTarget( + self, + action: #selector(handleNativeProfileTabTouchCancel), + for: [.touchCancel, .touchUpOutside] + ) - tabBar.cancelControlTrackingRecursively() + let longPressRecognizer = UILongPressGestureRecognizer( + target: self, + action: #selector(handleNativeProfileTabLongPress(_:)) + ) + longPressRecognizer.minimumPressDuration = 0.45 + longPressRecognizer.cancelsTouchesInView = true + profileTabTouchOverlay.addGestureRecognizer(longPressRecognizer) - let restoreDuration: TimeInterval = 0.24 - UIView.animate( - withDuration: restoreDuration, - delay: 0, - options: [.beginFromCurrentState, .allowUserInteraction, .curveEaseInOut] - ) { - self.tabBar.selectedItem = item - self.tabBar.layoutIfNeeded() - } + profileTabTouchOverlay.alpha = 0 + profileTabTouchOverlay.isHidden = true + view.addSubview(profileTabTouchOverlay) + updateProfileTabTouchOverlayFrame() } - private func cancelNativeTabTracking() { - tabBar.cancelControlTrackingRecursively() - tabBar.gestureRecognizers?.forEach { recognizer in - guard recognizer !== profileLongPressRecognizer else { return } - recognizer.isEnabled = false - recognizer.isEnabled = true + private func restoreProfileTabTouchIfNeeded() { + let tab = profileTouchRestoreTab ?? currentNativeSelectedTab + tabBar.selectedItem = tabBar.items?.first(where: { $0.tag == tab.tag }) + profileTouchRestoreTab = nil + } + + private func updateProfileTabTouchOverlayFrame() { + let tabCount = CGFloat(NativeTab.allCases.count) + guard tabCount > 0, tabBar.bounds.width > 0 else { + profileTabTouchOverlay.frame = .zero + return } - tabBar.isUserInteractionEnabled = false - DispatchQueue.main.async { [weak self] in - self?.tabBar.cancelControlTrackingRecursively() - self?.tabBar.isUserInteractionEnabled = true + + let itemWidth = tabBar.bounds.width / tabCount + let settingsIndex = CGFloat(NativeTab.settings.tag) + let visualIndex: CGFloat + if tabBar.effectiveUserInterfaceLayoutDirection == .rightToLeft { + visualIndex = tabCount - 1 - settingsIndex + } else { + visualIndex = settingsIndex } + let overlayFrameInTabBar = CGRect( + x: itemWidth * visualIndex, + y: 0, + width: itemWidth, + height: tabBar.bounds.height + ) + profileTabTouchOverlay.frame = tabBar.convert(overlayFrameInTabBar, to: view) + profileTabTouchOverlay.alpha = tabBar.alpha + view.bringSubviewToFront(profileTabTouchOverlay) } private func applyNativeTabBarAppearance() { @@ -685,6 +706,7 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate, UIGes guard let tab = NativeTab(tag: item.tag) else { return } item.title = tab.localizedTitle() } + profileTabTouchOverlay.accessibilityLabel = NativeTab.settings.localizedTitle() } private func nativeTabImage(for tab: NativeTab, selected: Bool, accent: UIColor) -> UIImage { @@ -757,16 +779,6 @@ private extension String { } } -private extension UIView { - func cancelControlTrackingRecursively() { - if let control = self as? UIControl { - control.cancelTracking(with: nil) - control.isHighlighted = false - } - subviews.forEach { $0.cancelControlTrackingRecursively() } - } -} - struct ComposeView: UIViewControllerRepresentable { func makeUIViewController(context: Context) -> UIViewController { // Register MPV player bridge before Compose initializes