From 053c6b0de57f10ac6b225c73885f244f791cf7d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Tue, 29 Sep 2026 12:10:19 +0100 Subject: [PATCH 1/4] Fix the Android findings from the Video SDK design testing round SDK: - Center the CallAppBar center content to the bar by giving the leading and trailing slots equal weight. - Open the participant actions menu 8dp below its button. - Cap the UserAvatarBackground avatar at half of the container's shorter side, so it fits the PiP window and the floating self-view. - In Picture-in-Picture, hide the participant actions button (the window takes no touches), drop the tile's own corner clip (the system clips the window with its own radius), and skip the avatar crossfade, which left the avatar image empty. Demo app: - Render the reaction emojis with StreamButton, since the StreamTextButton label padding truncated them to an ellipsis. - Reduce the call controls spacing from 16dp to 8dp. --- .../video/android/ui/call/CallScreen.kt | 10 ++--- .../video/android/ui/call/ReactionsMenu.kt | 40 +++++++++--------- .../ui/components/call/CallAppBarPreview.kt | 21 +++++++++ .../compose/ui/components/avatar/Avatar.kt | 7 ++- .../components/avatar/UserAvatarBackground.kt | 8 ++-- .../compose/ui/components/call/CallAppBar.kt | 20 +++++++-- .../call/pinning/ParticipantActions.kt | 12 +++--- .../call/renderer/ParticipantVideo.kt | 9 +++- ...aitTest_call_app_bar_with_uneven_slots.png | Bin 0 -> 8529 bytes .../compose/CallComponentsPortraitTest.kt | 8 ++++ 10 files changed, 97 insertions(+), 38 deletions(-) create mode 100644 stream-video-android-ui-compose/src/test/snapshots/images/io.getstream.video.android.compose_CallComponentsPortraitTest_call_app_bar_with_uneven_slots.png diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/ui/call/CallScreen.kt b/demo-app/src/main/kotlin/io/getstream/video/android/ui/call/CallScreen.kt index 11639bfee9f..170bda45079 100644 --- a/demo-app/src/main/kotlin/io/getstream/video/android/ui/call/CallScreen.kt +++ b/demo-app/src/main/kotlin/io/getstream/video/android/ui/call/CallScreen.kt @@ -400,7 +400,7 @@ fun CallScreen( isShowingSettingMenu = !isShowingSettingMenu }, ) - Spacer(modifier = Modifier.size(StreamTokens.spacingMd)) + Spacer(modifier = Modifier.size(StreamTokens.spacingXs)) if (isTablet()) { ScreenShareToggleAction( active = isScreenSharing, @@ -421,7 +421,7 @@ fun CallScreen( }, ) Spacer( - modifier = Modifier.size(StreamTokens.spacingMd), + modifier = Modifier.size(StreamTokens.spacingXs), ) } ToggleCameraAction( @@ -432,7 +432,7 @@ fun CallScreen( isCameraEnabled = isCameraEnabled, onCallAction = { call.camera.setEnabled(it.isEnabled) }, ) - Spacer(modifier = Modifier.size(StreamTokens.spacingMd)) + Spacer(modifier = Modifier.size(StreamTokens.spacingXs)) ToggleMicrophoneAction( modifier = Modifier .testTag( @@ -445,7 +445,7 @@ fun CallScreen( ) }, ) - Spacer(modifier = Modifier.size(StreamTokens.spacingMd)) + Spacer(modifier = Modifier.size(StreamTokens.spacingXs)) } Row { StreamBadgeBox( @@ -459,7 +459,7 @@ fun CallScreen( showParticipants = !showParticipants } } - Spacer(modifier = Modifier.size(StreamTokens.spacingMd)) + Spacer(modifier = Modifier.size(StreamTokens.spacingXs)) ChatDialogAction( modifier = Modifier.testTag("Stream_ChatButton"), messageCount = unreadCount, diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/ui/call/ReactionsMenu.kt b/demo-app/src/main/kotlin/io/getstream/video/android/ui/call/ReactionsMenu.kt index de2ae35d2e0..5ab05481227 100644 --- a/demo-app/src/main/kotlin/io/getstream/video/android/ui/call/ReactionsMenu.kt +++ b/demo-app/src/main/kotlin/io/getstream/video/android/ui/call/ReactionsMenu.kt @@ -24,8 +24,8 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.requiredHeight -import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier @@ -33,6 +33,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import io.getstream.android.core.ui.design.StreamTokens import io.getstream.video.android.compose.theme.VideoTheme +import io.getstream.video.android.compose.ui.components.base.StreamButton import io.getstream.video.android.compose.ui.components.base.StreamButtonSize import io.getstream.video.android.compose.ui.components.base.StreamButtonStyleDefaults import io.getstream.video.android.compose.ui.components.base.StreamTextButton @@ -120,25 +121,26 @@ private fun ReactionItem( onEmojiSelected: (emoji: String) -> Unit, ) { val mappedEmoji = reactionMapper.map(reaction.emojiCode) - val text = if (showText) { - "$mappedEmoji ${reaction.displayText}" - } else { - mappedEmoji - } - val modifier = if (showText) { - Modifier.fillMaxWidth() + val onClick = { onEmojiSelected(reaction.emojiCode) } + if (showText) { + StreamTextButton( + modifier = Modifier.fillMaxWidth(), + style = StreamButtonStyleDefaults.secondaryOutline, + size = StreamButtonSize.Small, + text = "$mappedEmoji ${reaction.displayText}", + onClick = onClick, + ) } else { - Modifier - .requiredWidth(StreamTokens.size48) - .requiredHeight(StreamTokens.size48) + // A plain StreamButton: the label padding of StreamTextButton leaves no room for the emoji. + StreamButton( + modifier = Modifier.requiredSize(StreamTokens.size48), + style = StreamButtonStyleDefaults.secondaryOutline, + size = StreamButtonSize.Small, + onClick = onClick, + ) { + Text(text = mappedEmoji) + } } - StreamTextButton( - modifier = modifier, - style = StreamButtonStyleDefaults.secondaryOutline, - size = StreamButtonSize.Small, - text = text, - onClick = { onEmojiSelected(reaction.emojiCode) }, - ) } private fun sendReaction(scope: CoroutineScope, call: Call, emoji: String, onDismiss: () -> Unit) { diff --git a/stream-video-android-ui-compose/src/debug/kotlin/io/getstream/video/android/compose/ui/components/call/CallAppBarPreview.kt b/stream-video-android-ui-compose/src/debug/kotlin/io/getstream/video/android/compose/ui/components/call/CallAppBarPreview.kt index 68e9660a2cd..78ad3d4fcb4 100644 --- a/stream-video-android-ui-compose/src/debug/kotlin/io/getstream/video/android/compose/ui/components/call/CallAppBarPreview.kt +++ b/stream-video-android-ui-compose/src/debug/kotlin/io/getstream/video/android/compose/ui/components/call/CallAppBarPreview.kt @@ -20,6 +20,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import io.getstream.video.android.compose.theme.VideoTheme +import io.getstream.video.android.compose.ui.components.call.controls.actions.FlipCameraAction import io.getstream.video.android.mock.StreamPreviewDataUtils import io.getstream.video.android.mock.previewCall @@ -36,3 +37,23 @@ private fun CallAppBarRootPreview() { internal fun CallAppBarPreview() { CallAppBar(call = previewCall) } + +@Preview +@Composable +private fun CallAppBarUnevenSlotsRootPreview() { + StreamPreviewDataUtils.initializeStreamVideo(LocalContext.current) + VideoTheme { + CallAppBarUnevenSlotsPreview() + } +} + +@Composable +internal fun CallAppBarUnevenSlotsPreview() { + CallAppBar( + call = previewCall, + leadingContent = { + DefaultCallAppBarLeadingContent(onBackButtonClicked = {}) + FlipCameraAction(onCallAction = {}) + }, + ) +} diff --git a/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/avatar/Avatar.kt b/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/avatar/Avatar.kt index 9813fafe571..18300dad72d 100644 --- a/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/avatar/Avatar.kt +++ b/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/avatar/Avatar.kt @@ -41,6 +41,7 @@ import com.skydoves.landscapist.coil.CoilImage import com.skydoves.landscapist.components.rememberImageComponent import com.skydoves.landscapist.placeholder.placeholder.PlaceholderPlugin import io.getstream.android.core.ui.design.StreamTokens +import io.getstream.video.android.compose.pip.rememberIsInPipMode import io.getstream.video.android.compose.theme.VideoTheme /** @@ -97,6 +98,8 @@ internal fun Avatar( modifier } + // The fade-in never finishes drawing in a Picture-in-Picture window and leaves the avatar empty. + val isInPictureInPicture = rememberIsInPipMode() CoilImage( modifier = clickableModifier.avatarBorder(shape).clip(shape), imageModel = { imageUrl }, @@ -107,7 +110,9 @@ internal fun Avatar( ), previewPlaceholder = painterResource(id = previewModePlaceholder), component = rememberImageComponent { - +CrossfadePlugin() + if (!isInPictureInPicture) { + +CrossfadePlugin() + } loadingPlaceholder?.let { +PlaceholderPlugin.Loading(painterResource(id = it)) } diff --git a/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/avatar/UserAvatarBackground.kt b/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/avatar/UserAvatarBackground.kt index 57d9b0fa02a..b1c8b198ae3 100644 --- a/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/avatar/UserAvatarBackground.kt +++ b/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/avatar/UserAvatarBackground.kt @@ -18,6 +18,7 @@ package io.getstream.video.android.compose.ui.components.avatar import androidx.annotation.DrawableRes import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape @@ -39,7 +40,8 @@ import io.getstream.video.android.model.User * @param userImage The URL of the image to be displayed. Usually [User.image]. * @param userName The name to be used for the initials fallback. Usually [User.name]. * @param shape The shape of the avatar. `CircleShape` by default. - * @param avatarSize The size of the avatar. + * @param avatarSize The size of the avatar. In a container smaller than twice this size, the avatar + * shrinks to half of the container's shorter side. * @param imageScale The scale rule used for the image. `Crop` by default. * @param imageDescription The image content description for accessibility. `Null` by default. * @param imageRequestSize The image size to be requested. Original size by default. @@ -63,10 +65,10 @@ public fun UserAvatarBackground( @DrawableRes previewModePlaceholder: Int = LocalAvatarPreviewProvider.getLocalAvatarPreviewPlaceholder(), textStyle: TextStyle? = null, ) { - Box(modifier = modifier.fillMaxSize()) { + BoxWithConstraints(modifier = modifier.fillMaxSize()) { Box( modifier = Modifier - .size(avatarSize) + .size(minOf(avatarSize, minOf(maxWidth, maxHeight) / 2)) .align(Alignment.Center), ) { UserAvatar( diff --git a/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/call/CallAppBar.kt b/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/call/CallAppBar.kt index 38716284e2d..8e6d1c920f3 100644 --- a/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/call/CallAppBar.kt +++ b/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/call/CallAppBar.kt @@ -59,7 +59,8 @@ import io.getstream.video.android.ui.common.R * Represents the default AppBar that's shown in calls. Exposes handlers for the two default slot * component implementations (leading and trailing). * - * Exposes slots required to customize the look and feel. + * Exposes slots required to customize the look and feel. The center content is centered to the + * bar, and the leading and trailing content each get half of the remaining width. * * @param call The call that contains all the participants state and tracks. * @param modifier Modifier for styling. @@ -111,11 +112,22 @@ public fun CallAppBar( .fillMaxWidth() .height(StreamTokens.size48), verticalAlignment = CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, ) { - leadingContent?.invoke(this) + // Equal weights keep the center content centered to the bar, whatever the side slots hold. + Row( + modifier = Modifier.weight(1f), + verticalAlignment = CenterVertically, + ) { + leadingContent?.invoke(this) + } centerContent?.invoke(this) - trailingContent?.invoke(this) + Row( + modifier = Modifier.weight(1f), + verticalAlignment = CenterVertically, + horizontalArrangement = Arrangement.End, + ) { + trailingContent?.invoke(this) + } } } diff --git a/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/call/pinning/ParticipantActions.kt b/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/call/pinning/ParticipantActions.kt index 3db2d5d7c42..e2d703afbbc 100644 --- a/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/call/pinning/ParticipantActions.kt +++ b/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/call/pinning/ParticipantActions.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInParent +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.IntOffset @@ -46,6 +47,7 @@ import androidx.lifecycle.lifecycleScope import io.getstream.android.core.ui.design.StreamTokens import io.getstream.android.video.generated.models.OwnCapability import io.getstream.video.android.compose.R +import io.getstream.video.android.compose.pip.rememberIsInPipMode import io.getstream.video.android.compose.theme.VideoTheme import io.getstream.video.android.compose.ui.components.base.StreamListItem import io.getstream.video.android.compose.ui.components.indicator.GenericIndicator @@ -163,10 +165,10 @@ internal fun BoxScope.ParticipantActionsWithoutState( ) { val buttonPosition = remember { mutableStateOf(Offset.Zero) } val buttonSize = remember { mutableStateOf(IntSize.Zero) } - if (actions.any { - it.condition.invoke(call, participant) - } - ) { + val menuGap = with(LocalDensity.current) { StreamTokens.spacingXs.roundToPx() } + // A Picture-in-Picture window does not pass touches to the app, so the button could not be used. + val isInPictureInPicture = rememberIsInPipMode() + if (!isInPictureInPicture && actions.any { it.condition.invoke(call, participant) }) { GenericIndicator( backgroundColor = VideoTheme.colors.backgroundCoreApp, shape = CircleShape, @@ -188,7 +190,7 @@ internal fun BoxScope.ParticipantActionsWithoutState( ParticipantActionsDialog( offset = IntOffset( x = buttonPosition.value.x.toInt(), - y = (buttonPosition.value.y + buttonSize.value.height).toInt(), + y = (buttonPosition.value.y + buttonSize.value.height).toInt() + menuGap, ), call = call, participant = participant, diff --git a/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/call/renderer/ParticipantVideo.kt b/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/call/renderer/ParticipantVideo.kt index 681f9c2e199..c55f6108713 100644 --- a/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/call/renderer/ParticipantVideo.kt +++ b/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/call/renderer/ParticipantVideo.kt @@ -55,6 +55,7 @@ import androidx.compose.ui.Alignment.Companion.Center import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.testTag @@ -64,6 +65,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import io.getstream.android.core.ui.design.StreamTokens +import io.getstream.video.android.compose.pip.rememberIsInPipMode import io.getstream.video.android.compose.theme.ParticipantLabelSoundIndicatorContentParams import io.getstream.video.android.compose.theme.ParticipantVideoActionsContentParams import io.getstream.video.android.compose.theme.ParticipantVideoConnectionIndicatorContentParams @@ -152,7 +154,12 @@ public fun ParticipantVideo( } } - val containerShape = RoundedCornerShape(StreamTokens.radiusXl) + // The system clips a Picture-in-Picture window with its own corner radius, which differs per device. + val containerShape = if (rememberIsInPipMode()) { + RectangleShape + } else { + RoundedCornerShape(StreamTokens.radiusXl) + } val containerModifier = if (style.isFocused && participants.size > 1) { modifier.border( border = if (style.isScreenSharing) { diff --git a/stream-video-android-ui-compose/src/test/snapshots/images/io.getstream.video.android.compose_CallComponentsPortraitTest_call_app_bar_with_uneven_slots.png b/stream-video-android-ui-compose/src/test/snapshots/images/io.getstream.video.android.compose_CallComponentsPortraitTest_call_app_bar_with_uneven_slots.png new file mode 100644 index 0000000000000000000000000000000000000000..42ae30f20ab1f2dce0f5eb1ee659b46ab18d2e90 GIT binary patch literal 8529 zcmYkCby!qg_xDwlkY+%-VMxiLOOb}5hLi@0p&3F#z#9;0rC}IQYUu8kPLb}C5a|Xb z-^26V&-Hu%nQN|n)?RC$z4qCAeZJ>}YpN^aKc#y5;K2iYWhFW72M-=Wf&ZVeF@ayQ zZnO6f9xzfU%e~a`p4&^u@g~)648_6D$A0`qkRkFt(MyK+xY$|T4<9ntm*WhFQRlmp zrBc%J@(Hf21W{v$J(=n;7K)0JI`6`_uqG$dT;ehUJywoWUq7rqNnE-f4fifYzFK6ga7dYi7YlhDACHY zYtoZiIk>v!hpQH1+~49K|8Ghrx^IcQiI*h+Kqv#aWlF)JH1kq~@1QKFQ+vcF3V2 zd2*9C;#KSTF|pGTM6yctCT_5`GNNvq*DI$$J2%2^wla4MKOQrN ztar#k5u1_4z+Sfmob;C7?)AL`vR<|EH;;y;E{qKo`#D3EF?x+lUJIGNlEr3hgu?{& zt|wfot?_{F4YPlR;K(TTbA==2B1&{6>(dk#b!fAZsA-+{o9L7^X15VSrD)C!3b2WnzpWqA5N&EsicJkc^#?U^K^A z?h}+{3dA7d+bqg`SPapz*%M72smjwJ*VWr=B%Dmx8@;9|F+x|3ySL^E?h;8+9OejB z&Q8A5e%#C$q#n|As@jA7zLqFukYng6`G6mI=KWPLV11h-BIw@ zflee)7)w}mv?t2KlJe1JDyv7o*Y-qz?1k7|^!d0Xv9>a>B7cf@=8P`CrF^tR+P;#I z=WOSaaX{9dQ>u@X67>1IUXtSsKRkHi7lY|Jwi0Uo%_kg3 z5*bQlB#@=-&`SlzM`0k*L~S~AbuI^gc!?%*SINU};+p5H;je;8AE4+JP+{5IM2#|= zu**+w&G5NRX)N90+u^nEnIC~19SyEQ$W6T&4{)fZ@J9Hy=<(XIFjq~773KMr^0u|o#EqY}viL(5ECy6WB5 zy3*e+ZM9z=IFz+aeHIsK=B1GG

#j4h%~#&}*oCzL|_`YuZSG1mBqFw-D10F9%$e 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 Date: Tue, 29 Sep 2026 16:40:07 +0100 Subject: [PATCH 2/4] Rebuild the avatar image component when the Picture-in-Picture mode changes --- .../compose/ui/components/avatar/Avatar.kt | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/avatar/Avatar.kt b/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/avatar/Avatar.kt index 18300dad72d..9d4154d55e8 100644 --- a/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/avatar/Avatar.kt +++ b/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/avatar/Avatar.kt @@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.ripple import androidx.compose.runtime.Composable +import androidx.compose.runtime.key import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -109,12 +110,15 @@ internal fun Avatar( requestSize = imageRequestSize, ), previewPlaceholder = painterResource(id = previewModePlaceholder), - component = rememberImageComponent { - if (!isInPictureInPicture) { - +CrossfadePlugin() - } - loadingPlaceholder?.let { - +PlaceholderPlugin.Loading(painterResource(id = it)) + // rememberImageComponent keeps its first instance, so the key rebuilds it when the mode changes. + component = key(isInPictureInPicture) { + rememberImageComponent { + if (!isInPictureInPicture) { + +CrossfadePlugin() + } + loadingPlaceholder?.let { + +PlaceholderPlugin.Loading(painterResource(id = it)) + } } }, failure = { From 78665abcd4326b7bb433ba65e24351a5aae1ef43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Wed, 30 Sep 2026 10:24:13 +0100 Subject: [PATCH 3/4] Left-align the demo menu rows like in 1.x --- .../video/android/ui/menu/base/DynamicMenu.kt | 36 +++++++++++++++---- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/ui/menu/base/DynamicMenu.kt b/demo-app/src/main/kotlin/io/getstream/video/android/ui/menu/base/DynamicMenu.kt index 38838c53c61..f162453f7ac 100644 --- a/demo-app/src/main/kotlin/io/getstream/video/android/ui/menu/base/DynamicMenu.kt +++ b/demo-app/src/main/kotlin/io/getstream/video/android/ui/menu/base/DynamicMenu.kt @@ -18,15 +18,19 @@ package io.getstream.video.android.ui.menu.base import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Icon import androidx.compose.material.LinearProgressIndicator +import androidx.compose.material.LocalContentColor import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -40,12 +44,13 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import io.getstream.android.core.ui.design.StreamTokens import io.getstream.video.android.compose.theme.VideoTheme +import io.getstream.video.android.compose.ui.components.base.StreamButton import io.getstream.video.android.compose.ui.components.base.StreamButtonStyleDefaults import io.getstream.video.android.compose.ui.components.base.StreamIconButton -import io.getstream.video.android.compose.ui.components.base.StreamTextButton import io.getstream.video.android.ui.closedcaptions.ClosedCaptionUiState import io.getstream.video.android.ui.menu.AudioUsageVoiceCommunicationUiState import io.getstream.video.android.ui.menu.TranscriptionAvailableUiState @@ -237,7 +242,7 @@ private fun LazyListScope.menuItems( items(items.size) { index -> val item = items[index] val highlight = item.highlight - StreamTextButton( + StreamButton( modifier = Modifier.fillMaxWidth(), onClick = { val actionItem = item as? ActionMenuItem @@ -247,10 +252,29 @@ private fun LazyListScope.menuItems( onNewSubmenu(it) } }, - text = item.title, - leadingIcon = item.icon.painter(), - style = if (highlight) StreamButtonStyleDefaults.primaryGhost else StreamButtonStyleDefaults.secondaryGhost, - ) + style = StreamButtonStyleDefaults.secondaryGhost, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = StreamTokens.spacingMd), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(StreamTokens.spacingSm), + ) { + Icon( + painter = item.icon.painter(), + contentDescription = null, + modifier = Modifier.size(StreamTokens.iconSizeMd), + tint = if (highlight) VideoTheme.colors.accentPrimary else LocalContentColor.current, + ) + Text( + text = item.title, + style = VideoTheme.typography.bodyDefault, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } } } From 3cce67901fd86f6cd5da7579de2ebe5d9975d47e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Wed, 30 Sep 2026 14:09:54 +0100 Subject: [PATCH 4/4] Keep the call app bar side slots when the center content is long Measure the leading and trailing slots first and center the content in the space left between them, so a long center text no longer squeezes the back and leave buttons to zero width, and a weighted custom center fills the space between the buttons again. --- .../ui/components/call/CallAppBarPreview.kt | 23 ++++++++ .../compose/ui/components/call/CallAppBar.kt | 53 +++++++++++------- ...traitTest_call_app_bar_with_long_title.png | Bin 0 -> 11483 bytes .../compose/CallComponentsPortraitTest.kt | 8 +++ 4 files changed, 64 insertions(+), 20 deletions(-) create mode 100644 stream-video-android-ui-compose/src/test/snapshots/images/io.getstream.video.android.compose_CallComponentsPortraitTest_call_app_bar_with_long_title.png diff --git a/stream-video-android-ui-compose/src/debug/kotlin/io/getstream/video/android/compose/ui/components/call/CallAppBarPreview.kt b/stream-video-android-ui-compose/src/debug/kotlin/io/getstream/video/android/compose/ui/components/call/CallAppBarPreview.kt index 78ad3d4fcb4..0130ae9a730 100644 --- a/stream-video-android-ui-compose/src/debug/kotlin/io/getstream/video/android/compose/ui/components/call/CallAppBarPreview.kt +++ b/stream-video-android-ui-compose/src/debug/kotlin/io/getstream/video/android/compose/ui/components/call/CallAppBarPreview.kt @@ -57,3 +57,26 @@ internal fun CallAppBarUnevenSlotsPreview() { }, ) } + +@Preview +@Composable +private fun CallAppBarLongTitleRootPreview() { + StreamPreviewDataUtils.initializeStreamVideo(LocalContext.current) + VideoTheme { + CallAppBarLongTitlePreview() + } +} + +@Composable +internal fun CallAppBarLongTitlePreview() { + CallAppBar( + call = previewCall, + centerContent = { + CalLCenterContent( + text = "Weekly design review with the whole video team", + isRecording = false, + isReconnecting = false, + ) + }, + ) +} diff --git a/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/call/CallAppBar.kt b/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/call/CallAppBar.kt index 8e6d1c920f3..c65e5dd97a4 100644 --- a/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/call/CallAppBar.kt +++ b/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/call/CallAppBar.kt @@ -18,7 +18,6 @@ package io.getstream.video.android.compose.ui.components.call import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope @@ -35,6 +34,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.Layout import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -54,13 +54,14 @@ import io.getstream.video.android.core.Call import io.getstream.video.android.core.call.state.CallAction import io.getstream.video.android.core.recording.RecordingType import io.getstream.video.android.ui.common.R +import kotlin.math.max /** * Represents the default AppBar that's shown in calls. Exposes handlers for the two default slot * component implementations (leading and trailing). * - * Exposes slots required to customize the look and feel. The center content is centered to the - * bar, and the leading and trailing content each get half of the remaining width. + * Exposes slots required to customize the look and feel. The leading and trailing content keep + * their size, and the center content is centered to the bar in the space left between them. * * @param call The call that contains all the participants state and tracks. * @param modifier Modifier for styling. @@ -107,26 +108,38 @@ public fun CallAppBar( } }, ) { - Row( + Layout( + contents = listOf( + { Row(verticalAlignment = CenterVertically) { leadingContent?.invoke(this) } }, + { Row(verticalAlignment = CenterVertically) { centerContent?.invoke(this) } }, + { Row(verticalAlignment = CenterVertically) { trailingContent?.invoke(this) } }, + ), modifier = modifier .fillMaxWidth() .height(StreamTokens.size48), - verticalAlignment = CenterVertically, - ) { - // Equal weights keep the center content centered to the bar, whatever the side slots hold. - Row( - modifier = Modifier.weight(1f), - verticalAlignment = CenterVertically, - ) { - leadingContent?.invoke(this) - } - centerContent?.invoke(this) - Row( - modifier = Modifier.weight(1f), - verticalAlignment = CenterVertically, - horizontalArrangement = Arrangement.End, - ) { - trailingContent?.invoke(this) + ) { (leading, center, trailing), constraints -> + val width = constraints.maxWidth + val height = constraints.maxHeight + val slotConstraints = constraints.copy(minWidth = 0, minHeight = 0) + val leadingPlaceable = leading.single().measure(slotConstraints) + val trailingPlaceable = trailing.single().measure( + slotConstraints.copy(maxWidth = width - leadingPlaceable.width), + ) + // The side slots keep their size, and the center gets the space that keeps it centered to the bar. + val sideWidth = max(leadingPlaceable.width, trailingPlaceable.width) + val centerPlaceable = center.single().measure( + slotConstraints.copy(maxWidth = (width - 2 * sideWidth).coerceAtLeast(0)), + ) + layout(width, height) { + leadingPlaceable.placeRelative(0, (height - leadingPlaceable.height) / 2) + centerPlaceable.placeRelative( + (width - centerPlaceable.width) / 2, + (height - centerPlaceable.height) / 2, + ) + trailingPlaceable.placeRelative( + width - trailingPlaceable.width, + (height - trailingPlaceable.height) / 2, + ) } } } diff --git a/stream-video-android-ui-compose/src/test/snapshots/images/io.getstream.video.android.compose_CallComponentsPortraitTest_call_app_bar_with_long_title.png b/stream-video-android-ui-compose/src/test/snapshots/images/io.getstream.video.android.compose_CallComponentsPortraitTest_call_app_bar_with_long_title.png new file mode 100644 index 0000000000000000000000000000000000000000..b67aae419ddc860eb59f761534129090d60e1c6f GIT binary patch literal 11483 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 XlPI!-;1u@KGMu!y!iP#RqoDr