mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-27 23:12:24 +00:00
Add native liquid glass profile tab handling
This commit is contained in:
parent
53d600768f
commit
18d004ffe0
6 changed files with 116 additions and 109 deletions
|
|
@ -23,5 +23,3 @@ internal actual fun publishNativeProfileTabIcon(
|
|||
avatarImageUrl: String?,
|
||||
avatarBackgroundColorHex: String?,
|
||||
) = Unit
|
||||
|
||||
internal actual fun notifyNativeProfileSwitcherPopupDismissed() = Unit
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,5 +102,3 @@ internal expect fun publishNativeProfileTabIcon(
|
|||
avatarImageUrl: String?,
|
||||
avatarBackgroundColorHex: String?,
|
||||
)
|
||||
|
||||
internal expect fun notifyNativeProfileSwitcherPopupDismissed()
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue