Skip to content

Commit a8f634c

Browse files
committed
library: feat: Add ScrollEndHaptic modifier
* Provides haptic feedback when a scrollable container is flung to its start or end boundaries. * Just like xiaomi official?
1 parent 00198b3 commit a8f634c

File tree

9 files changed

+209
-32
lines changed

9 files changed

+209
-32
lines changed

docs/guide/utils.md

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ Normally, you don't need to use it actively. See the [ListPopup](../components/l
5050

5151
## Overscroll Effects
5252

53-
Miuix provides easy-to-use overscroll effects for smoother and more natural scrolling experiences.
53+
Miuix provides easy-to-use overscroll effects modifier for smoother and more natural scrolling experiences.
5454

5555
### Vertical Overscroll
5656

@@ -105,6 +105,28 @@ LazyColumn(
105105
* `springDamp`: Float, defines the spring damping for the rebound animation. Higher values result in less oscillation. Defaults to `1f`.
106106
* `isEnabled`: A lambda expression returning a Boolean, used to dynamically control whether the overscroll effect is enabled. By default, it is enabled only on Android and iOS platforms.
107107

108+
109+
## Scroll End Haptic Feedback
110+
111+
Miuix provides a `scrollEndHaptic` modifier to trigger haptic feedback when a scrollable container is flung to its start or end boundaries. This enhances the user experience by providing a tactile confirmation that the end of the list has been reached.
112+
113+
```kotlin
114+
LazyColumn(
115+
modifier = Modifier
116+
.fillMaxSize()
117+
// Add scroll end haptic feedback
118+
.scrollEndHaptic(
119+
hapticFeedbackType = HapticFeedbackType.LongPress // Default value
120+
)
121+
) {
122+
// List content
123+
}
124+
```
125+
126+
**Parameter Explanation:**
127+
128+
* `hapticFeedbackType`: Specifies the type of haptic feedback to be performed when the scroll reaches its end. Defaults to `HapticFeedbackType.TextHandleMove`. You can use other types available in `androidx.compose.ui.hapticfeedback.HapticFeedbackType`.
129+
108130
## Press Feedback Effects
109131

110132
Miuix provides visual feedback effects to enhance user interaction experience, improving operability through tactile-like responses.
@@ -175,10 +197,10 @@ Box(
175197

176198
The `PressFeedbackType` enum defines different types of visual feedback that can be applied when the component is pressed.
177199

178-
| Type | Description |
179-
|------|-------------|
180-
| None | No visual feedback |
181-
| Sink | Applies a sink effect, where the component scales down slightly when pressed |
200+
| Type | Description |
201+
| ---- | ------------------------------------------------------------------------------------- |
202+
| None | No visual feedback |
203+
| Sink | Applies a sink effect, where the component scales down slightly when pressed |
182204
| Tilt | Applies a tilt effect, where the component tilts slightly based on the touch position |
183205

184206
## Smooth Rounded Corners (SmoothRoundedCornerShape)

docs/zh_CN/guide/utils.md

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ PopupLayout(
5151

5252
## 越界回弹效果 (Overscroll)
5353

54-
Miuix 提供了易于使用的越界回弹效果,让滚动体验更加流畅自然。
54+
Miuix 提供了易于使用的越界回弹效果修饰符,让滚动体验更加流畅自然。
5555

5656
### 垂直越界回弹
5757

@@ -106,6 +106,27 @@ LazyColumn(
106106
* `springDamp`: 浮点数,定义回弹动画的弹簧阻尼。值越高,振荡越小。默认为 `1f`
107107
* `isEnabled`: 一个返回布尔值的 Lambda 表达式,用于动态控制是否启用越界回弹效果。默认情况下,仅在 Android 和 iOS 平台上启用。
108108

109+
## 滚动到边界触觉反馈
110+
111+
Miuix 提供了用于在可滚动容器快速滑动到其开始或结束边界时触发触觉反馈的修饰符,通过触觉反馈确认已到达边界增强用户的交互体验。
112+
113+
```kotlin
114+
LazyColumn(
115+
modifier = Modifier
116+
.fillMaxSize()
117+
// 添加滚动到边界触觉反馈
118+
.scrollEndHaptic(
119+
hapticFeedbackType = HapticFeedbackType.TextHandleMove // 默认值
120+
)
121+
) {
122+
// 列表内容
123+
}
124+
```
125+
126+
**参数说明:**
127+
128+
* `hapticFeedbackType`: 指定滚动到达末端时要执行的触觉反馈类型。默认为 `HapticFeedbackType.TextHandleMove`。您可以使用 `androidx.compose.ui.hapticfeedback.HapticFeedbackType` 中可用的其他类型。
129+
109130
## 按压反馈效果 (PressFeedback)
110131

111132
Miuix 提供了视觉反馈效果来增强用户交互体验,通过类似触觉的响应提升操作感。
@@ -176,10 +197,10 @@ Box(
176197

177198
`PressFeedbackType` 枚举定义了组件被按下时可以应用的不同类型的视觉反馈。
178199

179-
| 类型 | 说明 |
180-
|------|-------------|
181-
| None | 无视觉反馈 |
182-
| Sink | 应用下沉效果,组件在按下时轻微缩小 |
200+
| 类型 | 说明 |
201+
| ---- | -------------------------------------- |
202+
| None | 无视觉反馈 |
203+
| Sink | 应用下沉效果,组件在按下时轻微缩小 |
183204
| Tilt | 应用倾斜效果,组件根据触摸位置轻微倾斜 |
184205

185206
## 平滑圆角 (SmoothRoundedCornerShape)

example/src/commonMain/kotlin/MainPage.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import top.yukonga.miuix.kmp.basic.SmallTitle
3636
import top.yukonga.miuix.kmp.basic.Text
3737
import top.yukonga.miuix.kmp.theme.MiuixTheme
3838
import top.yukonga.miuix.kmp.utils.overScrollVertical
39+
import top.yukonga.miuix.kmp.utils.scrollEndHaptic
3940

4041
@Composable
4142
fun MainPage(
@@ -83,6 +84,7 @@ fun MainPage(
8384
if (maxWidth < 840.dp) {
8485
LazyColumn(
8586
modifier = Modifier
87+
.scrollEndHaptic()
8688
.overScrollVertical()
8789
.nestedScroll(topAppBarScrollBehavior.nestedScrollConnection),
8890
contentPadding = PaddingValues(top = padding.calculateTopPadding()),
@@ -160,6 +162,7 @@ fun MainPage(
160162
LazyColumn(
161163
modifier = Modifier
162164
.nestedScroll(topAppBarScrollBehavior.nestedScrollConnection)
165+
.scrollEndHaptic()
163166
.weight(1f),
164167
contentPadding = PaddingValues(top = padding.calculateTopPadding())
165168
) {
@@ -223,6 +226,7 @@ fun MainPage(
223226
LazyColumn(
224227
modifier = Modifier
225228
.nestedScroll(topAppBarScrollBehavior.nestedScrollConnection)
229+
.scrollEndHaptic()
226230
.padding(end = 12.dp, bottom = 12.dp)
227231
.weight(1f),
228232
contentPadding = PaddingValues(top = padding.calculateTopPadding())

example/src/commonMain/kotlin/SecondPage.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import top.yukonga.miuix.kmp.basic.rememberPullToRefreshState
2020
import top.yukonga.miuix.kmp.extra.SuperDropdown
2121
import top.yukonga.miuix.kmp.utils.getWindowSize
2222
import top.yukonga.miuix.kmp.utils.overScrollVertical
23+
import top.yukonga.miuix.kmp.utils.scrollEndHaptic
2324

2425
@Composable
2526
fun SecondPage(
@@ -50,6 +51,7 @@ fun SecondPage(
5051
) {
5152
LazyColumn(
5253
modifier = Modifier
54+
.scrollEndHaptic()
5355
.overScrollVertical()
5456
.nestedScroll(topAppBarScrollBehavior.nestedScrollConnection)
5557
.height(getWindowSize().height.dp),

example/src/commonMain/kotlin/ThirdPage.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import top.yukonga.miuix.kmp.extra.SuperSwitch
2424
import top.yukonga.miuix.kmp.theme.MiuixTheme
2525
import top.yukonga.miuix.kmp.utils.getWindowSize
2626
import top.yukonga.miuix.kmp.utils.overScrollVertical
27+
import top.yukonga.miuix.kmp.utils.scrollEndHaptic
2728

2829
@Composable
2930
fun ThirdPage(
@@ -58,6 +59,7 @@ fun ThirdPage(
5859
val showDialog = remember { mutableStateOf(false) }
5960
LazyColumn(
6061
modifier = Modifier
62+
.scrollEndHaptic()
6163
.overScrollVertical()
6264
.nestedScroll(topAppBarScrollBehavior.nestedScrollConnection)
6365
.height(getWindowSize().height.dp),

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -207,14 +207,17 @@ internal class SliderHapticState {
207207
object SliderDefaults {
208208
val MinHeight = 30.dp
209209

210+
/**
211+
* The type of haptic feedback to be used for the slider.
212+
*/
210213
enum class SliderHapticEffect {
211-
/** No haptic feedback */
214+
/** No haptic feedback. */
212215
None,
213216

214-
/** Haptic feedback at 0% and 100% */
217+
/** Haptic feedback at 0% and 100%. */
215218
Edge,
216219

217-
/** Haptic feedback at steps */
220+
/** Haptic feedback at steps. */
218221
Step
219222
}
220223

miuix/src/commonMain/kotlin/top/yukonga/miuix/kmp/utils/PressFeedback.kt

Lines changed: 9 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -16,28 +16,17 @@ import androidx.compose.ui.draw.scale
1616
import androidx.compose.ui.graphics.TransformOrigin
1717
import androidx.compose.ui.graphics.graphicsLayer
1818
import androidx.compose.ui.input.pointer.pointerInput
19-
import top.yukonga.miuix.kmp.utils.PressFeedbackType.None
20-
import top.yukonga.miuix.kmp.utils.PressFeedbackType.Sink
21-
import top.yukonga.miuix.kmp.utils.PressFeedbackType.Tilt
2219

23-
/**
24-
* Default sink amount for the press sink effect.
25-
*/
20+
/** Default sink amount for the press sink effect. */
2621
internal const val SinkAmount: Float = 0.94f
2722

28-
/**
29-
* Default tilt amount for the press tilt effect.
30-
*/
23+
/** Default tilt amount for the press tilt effect. */
3124
internal const val TiltAmount: Float = 8f
3225

33-
/**
34-
* Default damping ratio for the press feedback spring animations.
35-
*/
26+
/** Default damping ratio for the press feedback spring animations. */
3627
internal const val DampingRatio: Float = 0.6f
3728

38-
/**
39-
* Default stiffness for the press feedback spring animations.
40-
*/
29+
/** Default stiffness for the press feedback spring animations. */
4130
internal const val Stiffness: Float = 400f
4231

4332
/**
@@ -130,13 +119,14 @@ fun Modifier.pressTilt(
130119

131120
/**
132121
* The type of visual feedback to apply when the component is pressed.
133-
*
134-
* @property None No visual feedback.
135-
* @property Sink Sink effect, where the component scales down slightly when pressed.
136-
* @property Tilt Tilt effect, where the component tilts slightly based on the touch position.
137122
*/
138123
enum class PressFeedbackType {
124+
/** No feedback effect. */
139125
None,
126+
127+
/** Sinks slightly when pressed. */
140128
Sink,
129+
130+
/** Tilts based on touch position when pressed. */
141131
Tilt
142132
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package top.yukonga.miuix.kmp.utils
2+
3+
import androidx.compose.runtime.remember
4+
import androidx.compose.ui.Modifier
5+
import androidx.compose.ui.composed
6+
import androidx.compose.ui.geometry.Offset
7+
import androidx.compose.ui.hapticfeedback.HapticFeedback
8+
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
9+
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
10+
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
11+
import androidx.compose.ui.input.nestedscroll.nestedScroll
12+
import androidx.compose.ui.platform.LocalHapticFeedback
13+
import androidx.compose.ui.unit.Velocity
14+
import kotlin.math.abs
15+
16+
/**
17+
* A [NestedScrollConnection] that provides haptic feedback when a scrollable container
18+
* is flung to its start or end boundaries.
19+
*
20+
* @param hapticFeedback The [HapticFeedback] instance to perform feedback.
21+
* @param hapticFeedbackType The type of haptic feedback to perform.
22+
*/
23+
private class ScrollEndHapticConnection(
24+
private val hapticFeedback: HapticFeedback,
25+
private val hapticFeedbackType: HapticFeedbackType
26+
) : NestedScrollConnection {
27+
28+
private enum class OverscrollState {
29+
/** No overscroll detected. */
30+
Idle,
31+
32+
/** Overscroll detected at the top boundary. */
33+
TopBoundaryHit,
34+
35+
/** Overscroll detected at the bottom boundary. */
36+
BottomBoundaryHit
37+
}
38+
39+
private var overscrollState: OverscrollState = OverscrollState.Idle
40+
41+
private fun Float.filter(tolerance: Float): Boolean = abs(this) < tolerance
42+
43+
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
44+
// Reset state when scrolling from a boundary into content.
45+
if (overscrollState == OverscrollState.TopBoundaryHit && available.y < -1f) {
46+
overscrollState = OverscrollState.Idle
47+
} else if (overscrollState == OverscrollState.BottomBoundaryHit && available.y > 1f) {
48+
overscrollState = OverscrollState.Idle
49+
}
50+
return Offset.Zero
51+
}
52+
53+
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
54+
println("overScrollState: $overscrollState")
55+
// Flinging beyond the bottom boundary.
56+
if (available.y > 1f && !consumed.y.filter(5f)) {
57+
if (overscrollState != OverscrollState.TopBoundaryHit) {
58+
hapticFeedback.performHapticFeedback(hapticFeedbackType)
59+
overscrollState = OverscrollState.TopBoundaryHit
60+
}
61+
}
62+
// Flinging beyond the top boundary.
63+
else if (available.y < -1f && !consumed.y.filter(5f)) {
64+
if (overscrollState != OverscrollState.BottomBoundaryHit) {
65+
hapticFeedback.performHapticFeedback(hapticFeedbackType)
66+
overscrollState = OverscrollState.BottomBoundaryHit
67+
}
68+
}
69+
return Velocity.Zero
70+
}
71+
}
72+
73+
/**
74+
* Applies a haptic feedback effect when a scrollable container is flung to its boundaries.
75+
*
76+
* @param hapticFeedbackType The type of haptic feedback to perform.
77+
*/
78+
fun Modifier.scrollEndHaptic(
79+
hapticFeedbackType: HapticFeedbackType = HapticFeedbackType.TextHandleMove
80+
): Modifier = composed {
81+
val haptic = LocalHapticFeedback.current
82+
83+
val connection = remember(haptic, hapticFeedbackType) {
84+
ScrollEndHapticConnection(
85+
hapticFeedback = haptic,
86+
hapticFeedbackType = hapticFeedbackType
87+
)
88+
}
89+
Modifier.nestedScroll(connection)
90+
}

0 commit comments

Comments
 (0)