feat: added hold, hover and release to select faeture in profile switch bubble

This commit is contained in:
tapframe 2026-05-10 10:11:51 +05:30
parent c8c1dea761
commit b3a1589296
4 changed files with 128 additions and 13 deletions

View file

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

View file

@ -0,0 +1,7 @@
package com.nuvio.app.features.profiles
internal expect object ProfileHoverHapticFeedback {
fun prepare()
fun perform()
fun release()
}

View file

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

View file

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