Skip to content

Commit ae56e48

Browse files
authored
library: Refactor Dropdown and Spinner (#38)
* refactor(Dropdown/Spinner): refactor handling of layout and pointer events
1 parent d2d969f commit ae56e48

File tree

3 files changed

+250
-184
lines changed

3 files changed

+250
-184
lines changed

miuix/src/commonMain/kotlin/top/yukonga/miuix/kmp/extra/SuperDropdown.kt

Lines changed: 131 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
package top.yukonga.miuix.kmp.extra
22

33
import androidx.compose.foundation.Image
4+
import androidx.compose.foundation.LocalIndication
45
import androidx.compose.foundation.background
56
import androidx.compose.foundation.clickable
67
import androidx.compose.foundation.gestures.detectTapGestures
8+
import androidx.compose.foundation.indication
79
import androidx.compose.foundation.interaction.MutableInteractionSource
10+
import androidx.compose.foundation.interaction.PressInteraction
811
import androidx.compose.foundation.layout.Arrangement
912
import androidx.compose.foundation.layout.PaddingValues
1013
import androidx.compose.foundation.layout.Row
@@ -13,10 +16,7 @@ import androidx.compose.foundation.layout.WindowInsetsSides
1316
import androidx.compose.foundation.layout.asPaddingValues
1417
import androidx.compose.foundation.layout.captionBar
1518
import androidx.compose.foundation.layout.displayCutout
16-
import androidx.compose.foundation.layout.fillMaxSize
17-
import androidx.compose.foundation.layout.heightIn
1819
import androidx.compose.foundation.layout.navigationBars
19-
import androidx.compose.foundation.layout.offset
2020
import androidx.compose.foundation.layout.only
2121
import androidx.compose.foundation.layout.padding
2222
import androidx.compose.foundation.layout.size
@@ -49,8 +49,9 @@ import androidx.compose.ui.graphics.ColorFilter
4949
import androidx.compose.ui.graphics.TransformOrigin
5050
import androidx.compose.ui.graphics.graphicsLayer
5151
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
52-
import androidx.compose.ui.input.pointer.PointerEventType
5352
import androidx.compose.ui.input.pointer.pointerInput
53+
import androidx.compose.ui.layout.Layout
54+
import androidx.compose.ui.layout.layout
5455
import androidx.compose.ui.layout.onGloballyPositioned
5556
import androidx.compose.ui.layout.positionInWindow
5657
import androidx.compose.ui.platform.LocalDensity
@@ -80,7 +81,6 @@ import top.yukonga.miuix.kmp.utils.MiuixPopupUtil.Companion.dismissPopup
8081
import top.yukonga.miuix.kmp.utils.MiuixPopupUtil.Companion.showPopup
8182
import top.yukonga.miuix.kmp.utils.SmoothRoundedCornerShape
8283
import top.yukonga.miuix.kmp.utils.getWindowSize
83-
import kotlin.math.max
8484
import kotlin.math.roundToInt
8585

8686
/**
@@ -121,21 +121,23 @@ fun SuperDropdown(
121121
onSelectedIndexChange: ((Int) -> Unit)?,
122122
) {
123123
val interactionSource = remember { MutableInteractionSource() }
124+
val isDropdownPreExpand = remember { mutableStateOf(false) }
124125
val isDropdownExpanded = remember { mutableStateOf(false) }
125126
val coroutineScope = rememberCoroutineScope()
126127
val held = remember { mutableStateOf<HoldDownInteraction.Hold?>(null) }
127128
val hapticFeedback = LocalHapticFeedback.current
128129
val actionColor = if (enabled) MiuixTheme.colorScheme.onSurfaceVariantActions else MiuixTheme.colorScheme.disabledOnSecondaryVariant
129130
var alignLeft by rememberSaveable { mutableStateOf(true) }
130-
var dropdownOffsetXPx by remember { mutableIntStateOf(0) }
131-
var dropdownOffsetYPx by remember { mutableIntStateOf(0) }
132-
var componentHeightPx by remember { mutableIntStateOf(0) }
133-
var componentWidthPx by remember { mutableIntStateOf(0) }
131+
var componentInnerOffsetXPx by remember { mutableIntStateOf(0) }
132+
var componentInnerOffsetYPx by remember { mutableIntStateOf(0) }
133+
var componentInnerHeightPx by remember { mutableIntStateOf(0) }
134+
var componentInnerWidthPx by remember { mutableIntStateOf(0) }
134135

136+
val density = LocalDensity.current
135137
val getWindowSize = rememberUpdatedState(getWindowSize())
136138
val windowHeightPx by rememberUpdatedState(getWindowSize.value.height)
137139
val windowWidthPx by rememberUpdatedState(getWindowSize.value.width)
138-
var transformOrigin by mutableStateOf(TransformOrigin.Center)
140+
var transformOrigin by remember { mutableStateOf(TransformOrigin.Center) }
139141

140142
DisposableEffect(Unit) {
141143
onDispose {
@@ -154,40 +156,74 @@ fun SuperDropdown(
154156

155157
BasicComponent(
156158
modifier = modifier
159+
.indication(
160+
interactionSource = interactionSource,
161+
indication = LocalIndication.current
162+
)
157163
.pointerInput(Unit) {
158-
awaitPointerEventScope {
159-
while (enabled) {
160-
val event = awaitPointerEvent()
161-
if (event.type != PointerEventType.Move) {
162-
val eventChange = event.changes.first()
163-
if (eventChange.pressed) {
164-
alignLeft = eventChange.position.x < (size.width / 2)
164+
detectTapGestures(
165+
onPress = { position ->
166+
if (enabled) {
167+
alignLeft = position.x < (size.width / 2)
168+
isDropdownPreExpand.value = true
169+
val pressInteraction = PressInteraction.Press(position)
170+
coroutineScope.launch {
171+
interactionSource.emit(pressInteraction)
172+
}
173+
val released = tryAwaitRelease()
174+
isDropdownPreExpand.value = false
175+
if (released) {
176+
isDropdownExpanded.value = enabled
177+
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
178+
coroutineScope.launch {
179+
interactionSource.emit(HoldDownInteraction.Hold().also {
180+
held.value = it
181+
})
182+
interactionSource.emit(PressInteraction.Release(pressInteraction))
183+
}
184+
} else {
185+
coroutineScope.launch {
186+
interactionSource.emit(PressInteraction.Cancel(pressInteraction))
187+
}
165188
}
166189
}
167190
}
168-
}
169-
}
170-
.onGloballyPositioned { coordinates ->
171-
if (isDropdownExpanded.value) {
172-
val positionInWindow = coordinates.positionInWindow()
173-
dropdownOffsetXPx = positionInWindow.x.toInt()
174-
dropdownOffsetYPx = positionInWindow.y.toInt()
175-
componentHeightPx = coordinates.size.height
176-
componentWidthPx = coordinates.size.width
177-
val xInWindow = dropdownOffsetXPx + if (mode == DropDownMode.AlwaysOnRight || !alignLeft) componentWidthPx else 0
178-
val yInWindow = dropdownOffsetYPx + componentHeightPx / 2
179-
transformOrigin = TransformOrigin(
180-
xInWindow / windowWidthPx.toFloat(),
181-
yInWindow / windowHeightPx.toFloat()
182-
)
183-
}
191+
)
184192
},
185-
interactionSource = interactionSource,
186193
insideMargin = insideMargin,
187194
title = title,
188195
titleColor = titleColor,
189196
summary = summary,
190197
summaryColor = summaryColor,
198+
leftAction = {
199+
if (isDropdownPreExpand.value) {
200+
Layout(
201+
content = {},
202+
modifier = Modifier.onGloballyPositioned { childCoordinates ->
203+
val parentCoordinates =
204+
childCoordinates.parentLayoutCoordinates ?: return@onGloballyPositioned
205+
val positionInWindow = parentCoordinates.positionInWindow()
206+
componentInnerOffsetXPx = positionInWindow.x.toInt()
207+
componentInnerOffsetYPx = positionInWindow.y.toInt()
208+
componentInnerHeightPx = parentCoordinates.size.height
209+
componentInnerWidthPx = parentCoordinates.size.width
210+
with(density) {
211+
val xInWindow = componentInnerOffsetXPx + if (mode == DropDownMode.AlwaysOnRight || !alignLeft)
212+
componentInnerWidthPx - 64.dp.roundToPx()
213+
else
214+
64.dp.roundToPx()
215+
val yInWindow = componentInnerOffsetYPx + componentInnerHeightPx / 2 - 56.dp.roundToPx()
216+
transformOrigin = TransformOrigin(
217+
xInWindow / windowWidthPx.toFloat(),
218+
yInWindow / windowHeightPx.toFloat()
219+
)
220+
}
221+
}
222+
) { _, _ ->
223+
layout(0, 0) {}
224+
}
225+
}
226+
},
191227
rightActions = {
192228
if (showValue) {
193229
Text(
@@ -210,17 +246,6 @@ fun SuperDropdown(
210246
contentDescription = null
211247
)
212248
},
213-
onClick = {
214-
if (enabled) {
215-
isDropdownExpanded.value = enabled
216-
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
217-
coroutineScope.launch {
218-
interactionSource.emit(HoldDownInteraction.Hold().also {
219-
held.value = it
220-
})
221-
}
222-
}
223-
},
224249
enabled = enabled
225250
)
226251
if (isDropdownExpanded.value) {
@@ -231,9 +256,6 @@ fun SuperDropdown(
231256
}
232257
}
233258

234-
val density = LocalDensity.current
235-
var offsetXPx by remember { mutableStateOf(0) }
236-
var offsetYPx by remember { mutableStateOf(0) }
237259
val textMeasurer = rememberTextMeasurer()
238260
val textStyle = remember { TextStyle(fontWeight = FontWeight.Medium, fontSize = 16.sp) }
239261
val textWidthDp = remember(items) { items.maxOfOrNull { with(density) { textMeasurer.measure(text = it, style = textStyle).size.width.toDp() } } }
@@ -246,18 +268,9 @@ fun SuperDropdown(
246268
val captionBarPx by rememberUpdatedState(
247269
with(density) { WindowInsets.captionBar.asPaddingValues().calculateBottomPadding().toPx() }.roundToInt()
248270
)
249-
val dropdownMaxHeight by rememberUpdatedState(with(density) {
250-
(windowHeightPx - statusBarPx - navigationBarPx - captionBarPx).toDp()
251-
})
252271
val dropdownElevation by rememberUpdatedState(with(density) {
253272
11.dp.toPx()
254273
})
255-
val insideLeftPx by rememberUpdatedState(with(density) {
256-
insideMargin.calculateLeftPadding(LayoutDirection.Ltr).toPx()
257-
}.roundToInt())
258-
val insideRightPx by rememberUpdatedState(with(density) {
259-
insideMargin.calculateRightPadding(LayoutDirection.Ltr).toPx()
260-
}.roundToInt())
261274
val insideTopPx by rememberUpdatedState(with(density) {
262275
insideMargin.calculateTopPadding().toPx()
263276
}.roundToInt())
@@ -283,37 +296,48 @@ fun SuperDropdown(
283296
} else {
284297
popupModifier
285298
}
286-
.fillMaxSize()
287299
.pointerInput(Unit) {
288300
detectTapGestures(
289301
onTap = {
290302
dismissPopup(isDropdownExpanded)
291303
}
292304
)
293305
}
294-
.offset(x = offsetXPx.dp / density.density, y = offsetYPx.dp / density.density)
295-
) {
296-
LazyColumn(
297-
modifier = Modifier
298-
.onGloballyPositioned { coordinates ->
299-
offsetXPx = if (mode == DropDownMode.AlwaysOnRight || !alignLeft) {
300-
dropdownOffsetXPx + componentWidthPx - insideRightPx - coordinates.size.width - paddingPx - if (defaultWindowInsetsPadding) displayCutoutLeftSize else 0
301-
} else {
302-
dropdownOffsetXPx + paddingPx + insideLeftPx - if (defaultWindowInsetsPadding) displayCutoutLeftSize else 0
303-
}
304-
offsetYPx = calculateOffsetYPx(
306+
.layout { measurable, constraints ->
307+
val placeable = measurable.measure(
308+
constraints.copy(
309+
minWidth = 200.dp.roundToPx(),
310+
minHeight = 50.dp.roundToPx(),
311+
maxHeight = windowHeightPx - statusBarPx - navigationBarPx - captionBarPx
312+
)
313+
)
314+
layout(constraints.maxWidth, constraints.maxHeight) {
315+
val xCoordinate = calculateOffsetXPx(
316+
componentInnerOffsetXPx,
317+
componentInnerWidthPx,
318+
placeable.width,
319+
paddingPx,
320+
displayCutoutLeftSize,
321+
defaultWindowInsetsPadding,
322+
(mode == DropDownMode.AlwaysOnRight || !alignLeft)
323+
)
324+
val yCoordinate = calculateOffsetYPx(
305325
windowHeightPx,
306-
dropdownOffsetYPx,
307-
coordinates.size.height,
308-
componentHeightPx,
326+
componentInnerOffsetYPx,
327+
placeable.height,
328+
componentInnerHeightPx,
309329
insideTopPx,
310330
insideBottomPx,
311331
statusBarPx,
312332
navigationBarPx,
313333
captionBarPx
314334
)
335+
placeable.place(xCoordinate, yCoordinate)
315336
}
316-
.heightIn(50.dp, dropdownMaxHeight)
337+
}
338+
) {
339+
LazyColumn(
340+
modifier = Modifier
317341
.align(AbsoluteAlignment.TopLeft)
318342
.graphicsLayer(
319343
shadowElevation = dropdownElevation,
@@ -409,6 +433,34 @@ fun DropdownImpl(
409433
}
410434
}
411435

436+
/**
437+
* Calculate the offset of the dropdown.
438+
*
439+
* @param componentInnerOffsetXPx The offset of the component inside.
440+
* @param componentInnerWidthPx The width of the component inside.
441+
* @param dropdownWidthPx The width of the dropdown.
442+
* @param extraPaddingPx The extra padding of the dropdown.
443+
* @param displayCutoutLeftSizePx The size of the display cutout on the left.
444+
* @param defaultWindowInsetsPadding Whether to apply default window insets padding to the dropdown.
445+
* @param alignRight Whether to align the dropdown to the right.
446+
* @return The offset of the dropdown.
447+
*/
448+
fun calculateOffsetXPx(
449+
componentInnerOffsetXPx: Int,
450+
componentInnerWidthPx: Int,
451+
dropdownWidthPx: Int,
452+
extraPaddingPx: Int,
453+
displayCutoutLeftSizePx: Int,
454+
defaultWindowInsetsPadding: Boolean,
455+
alignRight: Boolean
456+
): Int {
457+
return if (alignRight) {
458+
componentInnerOffsetXPx + componentInnerWidthPx - dropdownWidthPx - extraPaddingPx - if (defaultWindowInsetsPadding) displayCutoutLeftSizePx else 0
459+
} else {
460+
componentInnerOffsetXPx + extraPaddingPx - if (defaultWindowInsetsPadding) displayCutoutLeftSizePx else 0
461+
}
462+
}
463+
412464
/**
413465
* Calculate the offset of the dropdown.
414466
*
@@ -434,24 +486,16 @@ fun calculateOffsetYPx(
434486
navigationBarPx: Int,
435487
captionBarPx: Int
436488
): Int {
437-
return if (windowHeightPx - captionBarPx - navigationBarPx - dropdownOffsetPx - componentHeightPx > dropdownHeightPx) {
489+
return (if (windowHeightPx - captionBarPx - navigationBarPx - dropdownOffsetPx - componentHeightPx > dropdownHeightPx) {
438490
// Show below
439-
dropdownOffsetPx + componentHeightPx - insideBottomHeightPx / 2
491+
dropdownOffsetPx + componentHeightPx + insideBottomHeightPx / 2
440492
} else if (dropdownOffsetPx - statusBarPx > dropdownHeightPx) {
441493
// Show above
442-
dropdownOffsetPx - dropdownHeightPx + insideTopHeightPx / 2
443-
} else if (windowHeightPx - statusBarPx - captionBarPx - navigationBarPx <= dropdownHeightPx) {
444-
// Special handling when the height of the popup is maxsize (== windowHeightPx)
445-
statusBarPx
494+
dropdownOffsetPx - dropdownHeightPx - insideTopHeightPx / 2
446495
} else {
447-
val maxInsideHeight = max(insideTopHeightPx, insideBottomHeightPx)
448-
if (windowHeightPx - dropdownOffsetPx < dropdownHeightPx / 2 + captionBarPx + navigationBarPx + maxInsideHeight + componentHeightPx / 2) {
449-
windowHeightPx - dropdownHeightPx - maxInsideHeight - captionBarPx - navigationBarPx
450-
} else {
451-
val offset = dropdownOffsetPx - dropdownHeightPx / 2 + componentHeightPx / 2
452-
if (offset > maxInsideHeight + statusBarPx) offset else maxInsideHeight + statusBarPx
453-
}
454-
}
496+
// Middle
497+
dropdownOffsetPx + componentHeightPx / 2 - dropdownHeightPx / 2
498+
}).coerceIn(statusBarPx, windowHeightPx - captionBarPx - navigationBarPx)
455499
}
456500

457501
/**

0 commit comments

Comments
 (0)