Skip to content

Commit 2c2e9e8

Browse files
authored
Fix WaveformSlider thumb and progress fill misalignment (#6421)
* Fix WaveformSlider thumb and progress fill misalignment * Add WaveformSlider screenshot tests
1 parent 75cafde commit 2c2e9e8

9 files changed

Lines changed: 187 additions & 91 deletions

stream-chat-android-compose/api/stream-chat-android-compose.api

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1149,6 +1149,16 @@ public final class io/getstream/chat/android/compose/ui/components/audio/Composa
11491149
public final fun getLambda$655444177$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2;
11501150
}
11511151

1152+
public final class io/getstream/chat/android/compose/ui/components/audio/ComposableSingletons$WaveformSliderKt {
1153+
public static final field INSTANCE Lio/getstream/chat/android/compose/ui/components/audio/ComposableSingletons$WaveformSliderKt;
1154+
public fun <init> ()V
1155+
public final fun getLambda$-1036173266$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2;
1156+
public final fun getLambda$-1516446859$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2;
1157+
public final fun getLambda$-570083019$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2;
1158+
public final fun getLambda$53626762$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2;
1159+
public final fun getLambda$709997198$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2;
1160+
}
1161+
11521162
public final class io/getstream/chat/android/compose/ui/components/audio/WaveformSliderKt {
11531163
public static final fun StaticWaveformSlider (Ljava/util/List;FZLandroidx/compose/ui/Modifier;IZZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V
11541164
}

stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/audio/WaveformSlider.kt

Lines changed: 107 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -21,17 +21,16 @@ import androidx.compose.foundation.background
2121
import androidx.compose.foundation.border
2222
import androidx.compose.foundation.layout.Box
2323
import androidx.compose.foundation.layout.BoxScope
24+
import androidx.compose.foundation.layout.BoxWithConstraints
2425
import androidx.compose.foundation.layout.fillMaxSize
2526
import androidx.compose.foundation.layout.fillMaxWidth
2627
import androidx.compose.foundation.layout.height
2728
import androidx.compose.foundation.layout.offset
2829
import androidx.compose.foundation.layout.size
29-
import androidx.compose.foundation.layout.width
3030
import androidx.compose.foundation.progressSemantics
3131
import androidx.compose.foundation.shape.CircleShape
3232
import androidx.compose.runtime.Composable
3333
import androidx.compose.runtime.getValue
34-
import androidx.compose.runtime.mutableFloatStateOf
3534
import androidx.compose.runtime.mutableStateOf
3635
import androidx.compose.runtime.remember
3736
import androidx.compose.runtime.rememberUpdatedState
@@ -41,9 +40,7 @@ import androidx.compose.ui.Modifier
4140
import androidx.compose.ui.geometry.CornerRadius
4241
import androidx.compose.ui.geometry.Offset
4342
import androidx.compose.ui.geometry.Size
44-
import androidx.compose.ui.graphics.Color
4543
import androidx.compose.ui.graphics.graphicsLayer
46-
import androidx.compose.ui.layout.onSizeChanged
4744
import androidx.compose.ui.platform.LocalDensity
4845
import androidx.compose.ui.platform.LocalLayoutDirection
4946
import androidx.compose.ui.tooling.preview.Preview
@@ -60,7 +57,6 @@ import kotlin.random.Random
6057
*
6158
* @param modifier Modifier for styling.
6259
* @param waveformData The waveform data to display.
63-
* @param style The style for the waveform slider.
6460
* @param visibleBarLimit The number of bars to display at once.
6561
* @param adjustBarWidthToLimit Whether to adjust the bar width to fit the visible bar limit.
6662
* @param progress The current progress of the waveform.
@@ -84,43 +80,45 @@ public fun StaticWaveformSlider(
8480
) {
8581
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
8682
val currentProgress by rememberUpdatedState(progress)
87-
var widthPx by remember { mutableFloatStateOf(0f) }
88-
Box(
83+
BoxWithConstraints(
8984
modifier = modifier
9085
.fillMaxSize()
91-
.progressSemantics(value = progress)
92-
.onSizeChanged { size ->
93-
widthPx = size.width.toFloat()
94-
}
95-
.dragPointerInput(
96-
enabled = isThumbVisible,
97-
onDragStart = {
98-
onDragStart(it.toHorizontalProgress(widthPx, isRtl))
99-
},
100-
onDrag = {
101-
onDrag(it.toHorizontalProgress(widthPx, isRtl))
102-
},
103-
onDragStop = {
104-
onDragStop(it?.toHorizontalProgress(widthPx, isRtl) ?: currentProgress)
105-
},
106-
),
86+
.progressSemantics(value = progress),
10787
) {
108-
// Draw the waveform
109-
WaveformTrack(
110-
modifier = Modifier.fillMaxSize(),
111-
waveformData = waveformData,
112-
visibleBarLimit = visibleBarLimit,
113-
adjustBarWidthToLimit = adjustBarWidthToLimit,
114-
progress = progress,
115-
)
116-
117-
// Draw the thumb
118-
if (isThumbVisible) {
119-
WaveformHandle(
120-
isPlaying = isPlaying,
88+
val widthPx = constraints.maxWidth.toFloat()
89+
Box(
90+
modifier = Modifier
91+
.fillMaxSize()
92+
.dragPointerInput(
93+
enabled = isThumbVisible,
94+
onDragStart = {
95+
onDragStart(it.toHorizontalProgress(widthPx, isRtl))
96+
},
97+
onDrag = {
98+
onDrag(it.toHorizontalProgress(widthPx, isRtl))
99+
},
100+
onDragStop = {
101+
onDragStop(it?.toHorizontalProgress(widthPx, isRtl) ?: currentProgress)
102+
},
103+
),
104+
) {
105+
// Draw the waveform
106+
WaveformTrack(
107+
modifier = Modifier.fillMaxSize(),
108+
waveformData = waveformData,
109+
visibleBarLimit = visibleBarLimit,
110+
adjustBarWidthToLimit = adjustBarWidthToLimit,
121111
progress = progress,
122-
parentWidthPx = widthPx,
123112
)
113+
114+
// Draw the thumb
115+
if (isThumbVisible) {
116+
WaveformHandle(
117+
isPlaying = isPlaying,
118+
progress = progress,
119+
parentWidthPx = widthPx,
120+
)
121+
}
124122
}
125123
}
126124
}
@@ -175,25 +173,24 @@ internal fun WaveformTrack(
175173

176174
val totalBars = when (adjustBarWidthToLimit) {
177175
true -> visibleBarLimit
178-
else -> when (waveformData.size > visibleBarLimit) {
179-
true -> visibleBarLimit
180-
else -> waveformData.size
181-
}
176+
else -> minOf(waveformData.size, visibleBarLimit)
182177
}
183178
val visibleBars = minOf(visibleBarLimit, waveformData.size)
184179
var barCornerRadius by remember(totalBars) { mutableStateOf(CornerRadius.Zero) }
185180
Canvas(
186181
modifier = modifier.graphicsLayer { scaleX = if (isRtl) -1f else 1f },
187182
) {
183+
if (totalBars <= 0) return@Canvas
184+
188185
val canvasW = size.width
189186
val canvasH = size.height
190187
val spaceWidth = canvasW * BarSpacingRatio
191188
val barsWidth = canvasW - spaceWidth
192189
val totalSpaces = totalBars - 1
193190
val barWidth = barsWidth / totalBars
194-
val barSpacing = spaceWidth / totalSpaces
191+
val barSpacing = if (totalSpaces > 0) spaceWidth / totalSpaces else 0f
195192

196-
val thresholdX = canvasW * finalProgress * visibleBars / visibleBarLimit
193+
val thresholdX = canvasW * finalProgress * visibleBars / totalBars
197194
val halfHeight = canvasH / 2
198195
if (barCornerRadius.x != barWidth || barCornerRadius.y != barWidth) {
199196
barCornerRadius = CornerRadius(barWidth, barWidth)
@@ -227,61 +224,80 @@ internal fun WaveformTrack(
227224
}
228225
}
229226

230-
@Preview(showBackground = true)
227+
@Preview(showBackground = true, widthDp = 250)
231228
@Composable
232-
internal fun WaveformSeekBarPreview() {
233-
val rand = Random(50)
234-
val waveform = List(50) { rand.nextFloat() }
229+
private fun StaticWaveformSliderAtStartPreview() {
230+
ChatPreviewTheme { StaticWaveformSliderAtStart() }
231+
}
235232

236-
ChatPreviewTheme {
237-
Box(
238-
modifier = Modifier
239-
.width(250.dp)
240-
.height(60.dp)
241-
.background(Color.Cyan),
242-
contentAlignment = Alignment.Center,
243-
) {
244-
StaticWaveformSlider(
245-
modifier = Modifier
246-
.fillMaxWidth()
247-
.height(36.dp),
248-
waveformData = waveform,
249-
progress = 0.0f,
250-
isPlaying = true,
251-
)
252-
}
253-
}
233+
@Preview(showBackground = true, widthDp = 250)
234+
@Composable
235+
private fun StaticWaveformSliderMidwayPreview() {
236+
ChatPreviewTheme { StaticWaveformSliderMidway() }
254237
}
255238

256-
@Preview(showBackground = true)
239+
@Preview(showBackground = true, widthDp = 250)
257240
@Composable
258-
internal fun WaveformTrackPreview() {
259-
val waveform = mutableListOf<Float>()
260-
val barCount = 100
261-
for (i in 0 until barCount) {
262-
waveform.add((i + 1) / barCount.toFloat())
263-
}
241+
private fun StaticWaveformSliderPausedPreview() {
242+
ChatPreviewTheme { StaticWaveformSliderPaused() }
243+
}
264244

265-
ChatPreviewTheme {
266-
Box(
267-
modifier = Modifier
268-
.width(250.dp)
269-
.height(80.dp)
270-
.background(Color.Black),
271-
contentAlignment = Alignment.Center,
272-
) {
273-
WaveformTrack(
274-
modifier = Modifier
275-
.background(Color.Red)
276-
.fillMaxWidth()
277-
.height(60.dp),
278-
waveformData = waveform,
279-
progress = 0f,
280-
adjustBarWidthToLimit = true,
281-
visibleBarLimit = 100,
282-
)
283-
}
245+
@Preview(showBackground = true, widthDp = 250)
246+
@Composable
247+
private fun StaticWaveformSliderWithoutThumbPreview() {
248+
ChatPreviewTheme { StaticWaveformSliderWithoutThumb() }
249+
}
250+
251+
@Composable
252+
internal fun StaticWaveformSliderAtStart() = StaticWaveformSliderSample(progress = 0f, isPlaying = true)
253+
254+
@Composable
255+
internal fun StaticWaveformSliderMidway() = StaticWaveformSliderSample(progress = 0.5f, isPlaying = true)
256+
257+
@Composable
258+
internal fun StaticWaveformSliderPaused() = StaticWaveformSliderSample(progress = 0.3f, isPlaying = false)
259+
260+
@Composable
261+
internal fun StaticWaveformSliderWithoutThumb() =
262+
StaticWaveformSliderSample(progress = 0.7f, isPlaying = true, isThumbVisible = false)
263+
264+
@Composable
265+
private fun StaticWaveformSliderSample(progress: Float, isPlaying: Boolean, isThumbVisible: Boolean = true) {
266+
val previewWaveform = remember {
267+
val rand = Random(50)
268+
List(50) { rand.nextFloat() }
284269
}
270+
271+
StaticWaveformSlider(
272+
modifier = Modifier
273+
.fillMaxWidth()
274+
.height(36.dp),
275+
waveformData = previewWaveform,
276+
progress = progress,
277+
isPlaying = isPlaying,
278+
isThumbVisible = isThumbVisible,
279+
)
280+
}
281+
282+
@Preview(showBackground = true, widthDp = 250)
283+
@Composable
284+
private fun FullWaveformTrackPreview() {
285+
ChatPreviewTheme { FullWaveformTrack() }
286+
}
287+
288+
@Suppress("MagicNumber")
289+
@Composable
290+
internal fun FullWaveformTrack() {
291+
val waveform = List(100) { (it + 1) / 100f }
292+
WaveformTrack(
293+
modifier = Modifier
294+
.fillMaxWidth()
295+
.height(36.dp),
296+
waveformData = waveform,
297+
progress = 1f,
298+
adjustBarWidthToLimit = true,
299+
visibleBarLimit = 100,
300+
)
285301
}
286302

287303
private fun Offset.toHorizontalProgress(base: Float, isRtl: Boolean): Float {
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/*
2+
* Copyright (c) 2014-2026 Stream.io Inc. All rights reserved.
3+
*
4+
* Licensed under the Stream License;
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://github.com/GetStream/stream-chat-android/blob/main/LICENSE
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.getstream.chat.android.compose.ui.components.audio
18+
19+
import androidx.compose.runtime.CompositionLocalProvider
20+
import androidx.compose.ui.platform.LocalLayoutDirection
21+
import androidx.compose.ui.unit.LayoutDirection
22+
import app.cash.paparazzi.DeviceConfig
23+
import app.cash.paparazzi.Paparazzi
24+
import com.android.ide.common.rendering.api.SessionParams
25+
import io.getstream.chat.android.compose.ui.PaparazziComposeTest
26+
import org.junit.Rule
27+
import org.junit.Test
28+
29+
internal class WaveformSliderTest : PaparazziComposeTest {
30+
31+
@get:Rule
32+
override val paparazzi = Paparazzi(
33+
deviceConfig = DeviceConfig.PIXEL_2,
34+
renderingMode = SessionParams.RenderingMode.SHRINK,
35+
)
36+
37+
@Test
38+
fun `slider playing at start`() {
39+
snapshotWithDarkModeRow { StaticWaveformSliderAtStart() }
40+
}
41+
42+
@Test
43+
fun `slider playing midway`() {
44+
snapshotWithDarkModeRow { StaticWaveformSliderMidway() }
45+
}
46+
47+
@Test
48+
fun `slider paused`() {
49+
snapshotWithDarkModeRow { StaticWaveformSliderPaused() }
50+
}
51+
52+
@Test
53+
fun `slider without thumb`() {
54+
snapshotWithDarkModeRow { StaticWaveformSliderWithoutThumb() }
55+
}
56+
57+
@Test
58+
fun `full track`() {
59+
snapshotWithDarkModeRow { FullWaveformTrack() }
60+
}
61+
62+
@Test
63+
fun `slider playing midway rtl`() {
64+
snapshotWithDarkModeRow {
65+
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
66+
StaticWaveformSliderMidway()
67+
}
68+
}
69+
}
70+
}
Loading
Loading
Loading
Loading
Loading
Loading

0 commit comments

Comments
 (0)