mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-16 23:12:12 +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.border
|
||||
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.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
|
|
@ -40,6 +40,7 @@ import androidx.compose.material3.Text
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
|
|
@ -48,10 +49,15 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
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.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
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.LocalHapticFeedback
|
||||
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.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.isIos
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
|
|
@ -97,6 +104,52 @@ fun ProfileSwitcherTab(
|
|||
// Keep popup composed while exit animation plays
|
||||
var popupVisible by remember { mutableStateOf(false) }
|
||||
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
|
||||
val popupAlpha = remember { Animatable(0f) }
|
||||
|
|
@ -126,6 +179,7 @@ fun ProfileSwitcherTab(
|
|||
)
|
||||
}
|
||||
} else {
|
||||
ProfileHoverHapticFeedback.release()
|
||||
// Animate out
|
||||
launch { popupAlpha.animateTo(0f, tween(180, easing = FastOutSlowInEasing)) }
|
||||
launch { popupScale.animateTo(0.85f, tween(200, easing = FastOutSlowInEasing)) }
|
||||
|
|
@ -134,21 +188,41 @@ fun ProfileSwitcherTab(
|
|||
// Remove from composition after animation completes
|
||||
popupVisible = false
|
||||
pinProfile = null
|
||||
dragTargetProfileIndex = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.onGloballyPositioned { triggerCoordinates = it }
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onClick,
|
||||
)
|
||||
.pointerInput(profiles) {
|
||||
detectTapGestures(
|
||||
onTap = { onClick() },
|
||||
onLongPress = {
|
||||
detectDragGesturesAfterLongPress(
|
||||
onDragStart = { startOffset ->
|
||||
if (profiles.isNotEmpty()) {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
performProfileHoldHaptic()
|
||||
ProfileHoverHapticFeedback.prepare()
|
||||
showPopup = true
|
||||
updateDragTarget(startOffset)
|
||||
}
|
||||
},
|
||||
onDrag = { change, _ ->
|
||||
change.consume()
|
||||
updateDragTarget(change.position)
|
||||
},
|
||||
onDragEnd = {
|
||||
ProfileHoverHapticFeedback.release()
|
||||
chooseDragTarget()
|
||||
},
|
||||
onDragCancel = {
|
||||
ProfileHoverHapticFeedback.release()
|
||||
dragTargetProfileIndex = null
|
||||
},
|
||||
)
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
|
|
@ -199,20 +273,20 @@ fun ProfileSwitcherTab(
|
|||
profile.profileIndex == activeProfile?.profileIndex
|
||||
val isPinTarget =
|
||||
pinProfile?.profileIndex == profile.profileIndex
|
||||
val isDragTarget =
|
||||
dragTargetProfileIndex == profile.profileIndex
|
||||
|
||||
PopupProfileBubble(
|
||||
profile = profile,
|
||||
avatars = avatars,
|
||||
isActive = isActive,
|
||||
isSelected = isPinTarget,
|
||||
isSelected = isPinTarget || isDragTarget,
|
||||
delayMs = index * 50,
|
||||
onBoundsChanged = { bounds ->
|
||||
profileBubbleBounds[profile.profileIndex] = bounds
|
||||
},
|
||||
onClick = {
|
||||
if (profile.pinEnabled) {
|
||||
pinProfile = profile
|
||||
} else {
|
||||
onProfileSelected(profile)
|
||||
showPopup = false
|
||||
}
|
||||
chooseProfile(profile)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -335,6 +409,7 @@ private fun PopupProfileBubble(
|
|||
isActive: Boolean,
|
||||
isSelected: Boolean,
|
||||
delayMs: Int,
|
||||
onBoundsChanged: (Rect) -> Unit,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val avatarColor = remember(profile.avatarColorHex) { parseHexColor(profile.avatarColorHex) }
|
||||
|
|
@ -363,7 +438,7 @@ private fun PopupProfileBubble(
|
|||
}
|
||||
|
||||
val pressScale by animateFloatAsState(
|
||||
targetValue = if (isSelected) 1.15f else 1f,
|
||||
targetValue = if (isSelected) 1.08f else 1f,
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessLow,
|
||||
|
|
@ -374,6 +449,9 @@ private fun PopupProfileBubble(
|
|||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.onGloballyPositioned { coordinates ->
|
||||
onBoundsChanged(coordinates.boundsInWindow())
|
||||
}
|
||||
.graphicsLayer {
|
||||
alpha = itemAlpha.value
|
||||
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