mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-17 15:32:01 +00:00
feat: added hold, hover and release to select faeture in profile switch bubble
This commit is contained in:
parent
c8c1dea761
commit
b3a1589296
4 changed files with 128 additions and 13 deletions
|
|
@ -0,0 +1,7 @@
|
||||||
|
package com.nuvio.app.features.profiles
|
||||||
|
|
||||||
|
internal actual object ProfileHoverHapticFeedback {
|
||||||
|
actual fun prepare() = Unit
|
||||||
|
actual fun perform() = Unit
|
||||||
|
actual fun release() = Unit
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
package com.nuvio.app.features.profiles
|
||||||
|
|
||||||
|
internal expect object ProfileHoverHapticFeedback {
|
||||||
|
fun prepare()
|
||||||
|
fun perform()
|
||||||
|
fun release()
|
||||||
|
}
|
||||||
|
|
@ -14,7 +14,7 @@ import androidx.compose.animation.shrinkVertically
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
|
@ -40,6 +40,7 @@ import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
|
@ -48,10 +49,15 @@ import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.draw.shadow
|
import androidx.compose.ui.draw.shadow
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Rect
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.layout.LayoutCoordinates
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.layout.boundsInWindow
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
|
@ -64,6 +70,7 @@ import androidx.compose.ui.window.Popup
|
||||||
import androidx.compose.ui.window.PopupProperties
|
import androidx.compose.ui.window.PopupProperties
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
|
import com.nuvio.app.isIos
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import nuvio.composeapp.generated.resources.*
|
import nuvio.composeapp.generated.resources.*
|
||||||
|
|
@ -97,6 +104,52 @@ fun ProfileSwitcherTab(
|
||||||
// Keep popup composed while exit animation plays
|
// Keep popup composed while exit animation plays
|
||||||
var popupVisible by remember { mutableStateOf(false) }
|
var popupVisible by remember { mutableStateOf(false) }
|
||||||
var pinProfile by remember { mutableStateOf<NuvioProfile?>(null) }
|
var pinProfile by remember { mutableStateOf<NuvioProfile?>(null) }
|
||||||
|
var dragTargetProfileIndex by remember { mutableStateOf<Int?>(null) }
|
||||||
|
var triggerCoordinates by remember { mutableStateOf<LayoutCoordinates?>(null) }
|
||||||
|
val profileBubbleBounds = remember(profiles.map { it.profileIndex }) {
|
||||||
|
mutableStateMapOf<Int, Rect>()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun performProfileHoldHaptic() {
|
||||||
|
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun performProfileHoverHaptic() {
|
||||||
|
if (isIos) {
|
||||||
|
ProfileHoverHapticFeedback.perform()
|
||||||
|
} else {
|
||||||
|
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateDragTarget(localPosition: Offset) {
|
||||||
|
val trigger = triggerCoordinates ?: return
|
||||||
|
val windowPosition = trigger.localToWindow(localPosition)
|
||||||
|
val nextTargetProfileIndex = profileBubbleBounds.entries
|
||||||
|
.firstOrNull { (_, bounds) -> bounds.contains(windowPosition) }
|
||||||
|
?.key
|
||||||
|
if (nextTargetProfileIndex != null && nextTargetProfileIndex != dragTargetProfileIndex) {
|
||||||
|
performProfileHoverHaptic()
|
||||||
|
}
|
||||||
|
dragTargetProfileIndex = nextTargetProfileIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
fun chooseProfile(profile: NuvioProfile) {
|
||||||
|
if (profile.pinEnabled) {
|
||||||
|
pinProfile = profile
|
||||||
|
} else {
|
||||||
|
onProfileSelected(profile)
|
||||||
|
showPopup = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun chooseDragTarget() {
|
||||||
|
val profile = profiles.firstOrNull { it.profileIndex == dragTargetProfileIndex }
|
||||||
|
dragTargetProfileIndex = null
|
||||||
|
if (profile != null) {
|
||||||
|
chooseProfile(profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Popup entrance/exit animation
|
// Popup entrance/exit animation
|
||||||
val popupAlpha = remember { Animatable(0f) }
|
val popupAlpha = remember { Animatable(0f) }
|
||||||
|
|
@ -126,6 +179,7 @@ fun ProfileSwitcherTab(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
ProfileHoverHapticFeedback.release()
|
||||||
// Animate out
|
// Animate out
|
||||||
launch { popupAlpha.animateTo(0f, tween(180, easing = FastOutSlowInEasing)) }
|
launch { popupAlpha.animateTo(0f, tween(180, easing = FastOutSlowInEasing)) }
|
||||||
launch { popupScale.animateTo(0.85f, tween(200, easing = FastOutSlowInEasing)) }
|
launch { popupScale.animateTo(0.85f, tween(200, easing = FastOutSlowInEasing)) }
|
||||||
|
|
@ -134,21 +188,41 @@ fun ProfileSwitcherTab(
|
||||||
// Remove from composition after animation completes
|
// Remove from composition after animation completes
|
||||||
popupVisible = false
|
popupVisible = false
|
||||||
pinProfile = null
|
pinProfile = null
|
||||||
|
dragTargetProfileIndex = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
|
.onGloballyPositioned { triggerCoordinates = it }
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
onClick = onClick,
|
||||||
|
)
|
||||||
.pointerInput(profiles) {
|
.pointerInput(profiles) {
|
||||||
detectTapGestures(
|
detectDragGesturesAfterLongPress(
|
||||||
onTap = { onClick() },
|
onDragStart = { startOffset ->
|
||||||
onLongPress = {
|
|
||||||
if (profiles.isNotEmpty()) {
|
if (profiles.isNotEmpty()) {
|
||||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
performProfileHoldHaptic()
|
||||||
|
ProfileHoverHapticFeedback.prepare()
|
||||||
showPopup = true
|
showPopup = true
|
||||||
|
updateDragTarget(startOffset)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onDrag = { change, _ ->
|
||||||
|
change.consume()
|
||||||
|
updateDragTarget(change.position)
|
||||||
|
},
|
||||||
|
onDragEnd = {
|
||||||
|
ProfileHoverHapticFeedback.release()
|
||||||
|
chooseDragTarget()
|
||||||
|
},
|
||||||
|
onDragCancel = {
|
||||||
|
ProfileHoverHapticFeedback.release()
|
||||||
|
dragTargetProfileIndex = null
|
||||||
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
|
|
@ -199,20 +273,20 @@ fun ProfileSwitcherTab(
|
||||||
profile.profileIndex == activeProfile?.profileIndex
|
profile.profileIndex == activeProfile?.profileIndex
|
||||||
val isPinTarget =
|
val isPinTarget =
|
||||||
pinProfile?.profileIndex == profile.profileIndex
|
pinProfile?.profileIndex == profile.profileIndex
|
||||||
|
val isDragTarget =
|
||||||
|
dragTargetProfileIndex == profile.profileIndex
|
||||||
|
|
||||||
PopupProfileBubble(
|
PopupProfileBubble(
|
||||||
profile = profile,
|
profile = profile,
|
||||||
avatars = avatars,
|
avatars = avatars,
|
||||||
isActive = isActive,
|
isActive = isActive,
|
||||||
isSelected = isPinTarget,
|
isSelected = isPinTarget || isDragTarget,
|
||||||
delayMs = index * 50,
|
delayMs = index * 50,
|
||||||
|
onBoundsChanged = { bounds ->
|
||||||
|
profileBubbleBounds[profile.profileIndex] = bounds
|
||||||
|
},
|
||||||
onClick = {
|
onClick = {
|
||||||
if (profile.pinEnabled) {
|
chooseProfile(profile)
|
||||||
pinProfile = profile
|
|
||||||
} else {
|
|
||||||
onProfileSelected(profile)
|
|
||||||
showPopup = false
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -335,6 +409,7 @@ private fun PopupProfileBubble(
|
||||||
isActive: Boolean,
|
isActive: Boolean,
|
||||||
isSelected: Boolean,
|
isSelected: Boolean,
|
||||||
delayMs: Int,
|
delayMs: Int,
|
||||||
|
onBoundsChanged: (Rect) -> Unit,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val avatarColor = remember(profile.avatarColorHex) { parseHexColor(profile.avatarColorHex) }
|
val avatarColor = remember(profile.avatarColorHex) { parseHexColor(profile.avatarColorHex) }
|
||||||
|
|
@ -363,7 +438,7 @@ private fun PopupProfileBubble(
|
||||||
}
|
}
|
||||||
|
|
||||||
val pressScale by animateFloatAsState(
|
val pressScale by animateFloatAsState(
|
||||||
targetValue = if (isSelected) 1.15f else 1f,
|
targetValue = if (isSelected) 1.08f else 1f,
|
||||||
animationSpec = spring(
|
animationSpec = spring(
|
||||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||||
stiffness = Spring.StiffnessLow,
|
stiffness = Spring.StiffnessLow,
|
||||||
|
|
@ -374,6 +449,9 @@ private fun PopupProfileBubble(
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
.onGloballyPositioned { coordinates ->
|
||||||
|
onBoundsChanged(coordinates.boundsInWindow())
|
||||||
|
}
|
||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
alpha = itemAlpha.value
|
alpha = itemAlpha.value
|
||||||
scaleX = itemScale.value * pressScale
|
scaleX = itemScale.value * pressScale
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
package com.nuvio.app.features.profiles
|
||||||
|
|
||||||
|
import platform.UIKit.UISelectionFeedbackGenerator
|
||||||
|
|
||||||
|
internal actual object ProfileHoverHapticFeedback {
|
||||||
|
private var generator: UISelectionFeedbackGenerator? = null
|
||||||
|
|
||||||
|
actual fun prepare() {
|
||||||
|
generator = UISelectionFeedbackGenerator().also { it.prepare() }
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun perform() {
|
||||||
|
val activeGenerator = generator ?: UISelectionFeedbackGenerator().also {
|
||||||
|
generator = it
|
||||||
|
}
|
||||||
|
activeGenerator.selectionChanged()
|
||||||
|
activeGenerator.prepare()
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun release() {
|
||||||
|
generator = null
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue