Skip to content

Commit 867929d

Browse files
committed
library: Optimize Switch interactions
1 parent 48fb58a commit 867929d

File tree

1 file changed

+114
-70
lines changed
  • miuix/src/commonMain/kotlin/top/yukonga/miuix/kmp/basic

1 file changed

+114
-70
lines changed

miuix/src/commonMain/kotlin/top/yukonga/miuix/kmp/basic/Switch.kt

Lines changed: 114 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,19 @@ import androidx.compose.animation.core.animateDpAsState
66
import androidx.compose.animation.core.spring
77
import androidx.compose.animation.core.tween
88
import androidx.compose.foundation.background
9+
import androidx.compose.foundation.gestures.Orientation
910
import androidx.compose.foundation.gestures.awaitEachGesture
1011
import androidx.compose.foundation.gestures.awaitFirstDown
1112
import androidx.compose.foundation.gestures.awaitVerticalTouchSlopOrCancellation
12-
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
13+
import androidx.compose.foundation.gestures.draggable
14+
import androidx.compose.foundation.gestures.rememberDraggableState
1315
import androidx.compose.foundation.gestures.waitForUpOrCancellation
16+
import androidx.compose.foundation.hoverable
17+
import androidx.compose.foundation.interaction.MutableInteractionSource
18+
import androidx.compose.foundation.interaction.PressInteraction
19+
import androidx.compose.foundation.interaction.collectIsDraggedAsState
20+
import androidx.compose.foundation.interaction.collectIsHoveredAsState
21+
import androidx.compose.foundation.interaction.collectIsPressedAsState
1422
import androidx.compose.foundation.layout.Box
1523
import androidx.compose.foundation.layout.padding
1624
import androidx.compose.foundation.layout.requiredSize
@@ -58,57 +66,46 @@ fun Switch(
5866
enabled: Boolean = true
5967
) {
6068
val isChecked by rememberUpdatedState(checked)
69+
70+
val interactionSource = remember { MutableInteractionSource() }
71+
val isPressed by interactionSource.collectIsPressedAsState()
72+
val isDragged by interactionSource.collectIsDraggedAsState()
73+
val isHovered by interactionSource.collectIsHoveredAsState()
74+
6175
val hapticFeedback = LocalHapticFeedback.current
62-
var hasVibrated by remember { mutableStateOf(false) }
76+
var hasVibrated by remember { mutableStateOf(true) }
77+
var hasVibratedOnce by remember { mutableStateOf(false) }
78+
6379
val springSpec = remember {
6480
spring<Dp>(
6581
dampingRatio = Spring.DampingRatioLowBouncy,
6682
stiffness = Spring.StiffnessMedium
6783
)
6884
}
69-
var isPressed by remember { mutableStateOf(false) }
85+
7086
var dragOffset by remember { mutableStateOf(0f) }
7187
val thumbOffset by animateDpAsState(
7288
targetValue = if (isChecked) {
73-
if (!enabled) 26.dp else if (isPressed) 24.dp else 26.dp
89+
if (!enabled) 26.dp else if (isPressed || isDragged || isHovered) 24.dp else 26.dp
7490
} else {
75-
if (!enabled) 4.dp else if (isPressed) 3.dp else 4.dp
91+
if (!enabled) 4.dp else if (isPressed || isDragged || isHovered) 3.dp else 4.dp
7692
} + dragOffset.dp,
7793
animationSpec = springSpec
7894
)
7995

8096
val thumbSize by animateDpAsState(
81-
targetValue = if (!enabled) 20.dp else if (isPressed) 23.dp else 20.dp,
97+
targetValue = if (!enabled) 20.dp else if (isPressed || isDragged || isHovered) 23.dp else 20.dp,
8298
animationSpec = springSpec
8399
)
84100

85-
val backgroundColor by animateColorAsState(
86-
if (isChecked) colors.checkedTrackColor(enabled) else colors.uncheckedTrackColor(enabled),
87-
animationSpec = tween(durationMillis = 200)
88-
)
89-
90101
val thumbColor by animateColorAsState(
91102
if (isChecked) colors.checkedThumbColor(enabled) else colors.uncheckedThumbColor(enabled)
92103
)
93104

94-
val toggleableModifier = remember(onCheckedChange, isChecked, enabled) {
95-
if (onCheckedChange != null) {
96-
Modifier.toggleable(
97-
value = isChecked,
98-
onValueChange = {
99-
onCheckedChange(it)
100-
if (it) hapticFeedback.performHapticFeedback(HapticFeedbackType.ToggleOn)
101-
else hapticFeedback.performHapticFeedback(HapticFeedbackType.ToggleOff)
102-
},
103-
enabled = enabled,
104-
role = Role.Switch,
105-
indication = null,
106-
interactionSource = null
107-
)
108-
} else {
109-
Modifier
110-
}
111-
}
105+
val backgroundColor by animateColorAsState(
106+
if (isChecked) colors.checkedTrackColor(enabled) else colors.uncheckedTrackColor(enabled),
107+
animationSpec = tween(durationMillis = 200)
108+
)
112109

113110
Box(
114111
modifier = modifier
@@ -117,37 +114,54 @@ fun Switch(
117114
.requiredSize(50.dp, 28.5.dp)
118115
.clip(RoundedCornerShape(100.dp))
119116
.drawBehind { drawRect(backgroundColor) }
117+
.hoverable(
118+
interactionSource = interactionSource,
119+
enabled = enabled
120+
)
120121
.pointerInput(Unit) {
121122
if (!enabled) return@pointerInput
122-
detectHorizontalDragGestures(
123-
onDragStart = {
124-
isPressed = true
125-
hasVibrated = false
126-
},
127-
onDragEnd = {
128-
isPressed = false
129-
val switchWidth = 21f
130-
if (dragOffset.absoluteValue > switchWidth / 2) {
131-
onCheckedChange?.invoke(!isChecked)
123+
val touchSlop = 16f
124+
awaitEachGesture {
125+
val down = awaitFirstDown(requireUnconsumed = false)
126+
val initialOffset = down.position
127+
var validHorizontalDrag = false
128+
do {
129+
val event = awaitPointerEvent()
130+
val currentOffset = event.changes[0].position
131+
val dx = (currentOffset.x - initialOffset.x).absoluteValue
132+
val dy = (currentOffset.y - initialOffset.y).absoluteValue
133+
if (dy > touchSlop) {
134+
validHorizontalDrag = false
135+
break
136+
} else if (dx > touchSlop) {
137+
validHorizontalDrag = true
132138
}
133-
dragOffset = 0f
134-
},
135-
onDragCancel = {
136-
isPressed = false
137-
dragOffset = 0f
138-
}
139-
) { _, dragAmount ->
140-
val newOffset = dragOffset + dragAmount / 2
141-
dragOffset = if (isChecked) newOffset.coerceIn(-21f, 0f) else newOffset.coerceIn(0f, 21f)
142-
if (dragOffset in -20f..-1f || dragOffset in 1f..20f) {
143-
hasVibrated = false
144-
} else if ((dragOffset == -21f || dragOffset == 0f || dragOffset == 21f) && !hasVibrated) {
145-
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
146-
hasVibrated = true
139+
} while (event.changes.all { it.pressed })
140+
141+
if (validHorizontalDrag && !isPressed && !isDragged) {
142+
onCheckedChange?.invoke(!isChecked)
143+
hapticFeedback.performHapticFeedback(
144+
if (isChecked) HapticFeedbackType.ToggleOff
145+
else HapticFeedbackType.ToggleOn
146+
)
147147
}
148148
}
149149
}
150-
.then(toggleableModifier)
150+
.toggleable(
151+
value = isChecked,
152+
onValueChange = {
153+
if (onCheckedChange == null) return@toggleable
154+
onCheckedChange.invoke(it)
155+
hapticFeedback.performHapticFeedback(
156+
if (it) HapticFeedbackType.ToggleOn
157+
else HapticFeedbackType.ToggleOff
158+
)
159+
},
160+
enabled = enabled,
161+
role = Role.Switch,
162+
indication = null,
163+
interactionSource = null
164+
)
151165
) {
152166
Box(
153167
modifier = Modifier
@@ -161,28 +175,58 @@ fun Switch(
161175
.pointerInput(Unit) {
162176
if (!enabled) return@pointerInput
163177
awaitEachGesture {
164-
awaitFirstDown().also {
165-
it.consume()
166-
isPressed = true
178+
val pressInteraction: PressInteraction.Press
179+
val down = awaitFirstDown().also {
180+
pressInteraction = PressInteraction.Press(it.position)
181+
interactionSource.tryEmit(pressInteraction)
167182
}
168-
waitForUpOrCancellation()?.also {
169-
it.consume()
170-
isPressed = false
171-
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
172-
onCheckedChange?.invoke(!isChecked)
183+
waitForUpOrCancellation().also {
184+
interactionSource.tryEmit(PressInteraction.Cancel(pressInteraction))
173185
}
174-
}
175-
}
176-
.pointerInput(Unit) {
177-
if (!enabled) return@pointerInput
178-
awaitEachGesture {
179-
val down = awaitFirstDown(requireUnconsumed = false)
180186
awaitVerticalTouchSlopOrCancellation(down.id) { _, _ ->
181-
isPressed = false
187+
interactionSource.tryEmit(PressInteraction.Cancel(pressInteraction))
182188
}
183189
}
184190
}
185-
191+
.draggable(
192+
state = rememberDraggableState { delta ->
193+
if (onCheckedChange == null) return@rememberDraggableState
194+
dragOffset = (dragOffset + delta / 2).let {
195+
if (isChecked) it.coerceIn(-21f, 0f) else it.coerceIn(0f, 21f)
196+
}
197+
if (dragOffset in -11f..-10f || dragOffset in 10f..11f) {
198+
hasVibratedOnce = false
199+
} else if (dragOffset in -20f..-1f || dragOffset in 1f..20f) {
200+
hasVibrated = false
201+
} else if (!hasVibrated) {
202+
if ((isChecked && dragOffset == -21f) || (!isChecked && dragOffset == 0f)) {
203+
hapticFeedback.performHapticFeedback(HapticFeedbackType.ToggleOff)
204+
hasVibrated = true
205+
hasVibratedOnce = true
206+
} else if ((isChecked && dragOffset == 0f) || (!isChecked && dragOffset == 21f)) {
207+
hapticFeedback.performHapticFeedback(HapticFeedbackType.ToggleOn)
208+
hasVibrated = true
209+
hasVibratedOnce = true
210+
}
211+
}
212+
},
213+
orientation = Orientation.Horizontal,
214+
enabled = enabled,
215+
interactionSource = interactionSource,
216+
onDragStopped = {
217+
if (dragOffset.absoluteValue > 21f / 2) onCheckedChange?.invoke(!isChecked)
218+
if (!hasVibratedOnce && dragOffset.absoluteValue >= 1f) {
219+
if ((isChecked && dragOffset <= -11f) || (!isChecked && dragOffset <= 10f)) {
220+
hapticFeedback.performHapticFeedback(HapticFeedbackType.ToggleOff)
221+
} else if ((isChecked && dragOffset >= -10f) || (!isChecked && dragOffset >= 11f)) {
222+
hapticFeedback.performHapticFeedback(HapticFeedbackType.ToggleOn)
223+
}
224+
}
225+
hasVibrated = true
226+
hasVibratedOnce = false
227+
dragOffset = 0f
228+
}
229+
)
186230
)
187231
}
188232
}

0 commit comments

Comments
 (0)