Add native liquid glass profile tab handling

This commit is contained in:
WhiteGiso 2026-06-18 14:59:22 +02:00
parent 53d600768f
commit 18d004ffe0
6 changed files with 116 additions and 109 deletions

View file

@ -23,5 +23,3 @@ internal actual fun publishNativeProfileTabIcon(
avatarImageUrl: String?,
avatarBackgroundColorHex: String?,
) = Unit
internal actual fun notifyNativeProfileSwitcherPopupDismissed() = Unit

View file

@ -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<TabsRoute>() == 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),
)
}
}

View file

@ -102,5 +102,3 @@ internal expect fun publishNativeProfileTabIcon(
avatarImageUrl: String?,
avatarBackgroundColorHex: String?,
)
internal expect fun notifyNativeProfileSwitcherPopupDismissed()

View file

@ -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 ->

View file

@ -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()

View file

@ -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