@@ -20,10 +20,14 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
2020import androidx.compose.foundation.isSystemInDarkTheme
2121import androidx.compose.foundation.layout.Arrangement
2222import androidx.compose.foundation.layout.Box
23+ import androidx.compose.foundation.layout.IntrinsicSize
2324import androidx.compose.foundation.layout.PaddingValues
2425import androidx.compose.foundation.layout.Row
26+ import androidx.compose.foundation.layout.calculateEndPadding
27+ import androidx.compose.foundation.layout.fillMaxSize
2528import androidx.compose.foundation.layout.heightIn
2629import androidx.compose.foundation.layout.padding
30+ import androidx.compose.foundation.layout.requiredWidth
2731import androidx.compose.foundation.layout.size
2832import androidx.compose.foundation.layout.widthIn
2933import androidx.compose.foundation.shape.RoundedCornerShape
@@ -32,15 +36,20 @@ import androidx.compose.material.icons.filled.FavoriteBorder
3236import androidx.compose.material3.Text
3337import androidx.compose.runtime.Composable
3438import androidx.compose.runtime.getValue
39+ import androidx.compose.runtime.mutableStateOf
3540import androidx.compose.runtime.remember
41+ import androidx.compose.runtime.setValue
3642import androidx.compose.ui.Alignment
3743import androidx.compose.ui.Modifier
3844import androidx.compose.ui.draw.alpha
3945import androidx.compose.ui.graphics.Color
4046import androidx.compose.ui.graphics.ImageBitmap
4147import androidx.compose.ui.graphics.painter.Painter
4248import androidx.compose.ui.graphics.vector.ImageVector
49+ import androidx.compose.ui.layout.onSizeChanged
4350import androidx.compose.ui.platform.LocalConfiguration
51+ import androidx.compose.ui.platform.LocalDensity
52+ import androidx.compose.ui.platform.LocalLayoutDirection
4453import androidx.compose.ui.res.stringResource
4554import androidx.compose.ui.semantics.Role
4655import androidx.compose.ui.semantics.contentDescription
@@ -52,11 +61,15 @@ import androidx.compose.ui.tooling.preview.Preview
5261import androidx.compose.ui.tooling.preview.PreviewLightDark
5362import androidx.compose.ui.tooling.preview.PreviewParameter
5463import androidx.compose.ui.unit.Dp
64+ import androidx.compose.ui.unit.IntOffset
65+ import androidx.compose.ui.unit.IntSize
66+ import androidx.compose.ui.unit.LayoutDirection
5567import androidx.compose.ui.unit.dp
5668import com.orange.ouds.core.R
5769import com.orange.ouds.core.component.common.outerBorder
5870import com.orange.ouds.core.component.content.OudsComponentContent
5971import com.orange.ouds.core.component.content.OudsComponentIcon
72+ import com.orange.ouds.core.component.content.OudsComponentIconBadge
6073import com.orange.ouds.core.extensions.InteractionState
6174import com.orange.ouds.core.extensions.collectInteractionStateAsState
6275import com.orange.ouds.core.theme.LocalColorMode
@@ -245,14 +258,15 @@ fun OudsButton(
245258
246259@Composable
247260@JvmName(" OudsButtonNullableIconAndLabel" )
248- private fun OudsButton (
261+ internal fun OudsButton (
249262 nullableIcon : OudsButtonIcon ? ,
250263 nullableLabel : String? ,
251264 onClick : () -> Unit ,
252265 modifier : Modifier = Modifier ,
253266 enabled : Boolean = true,
254267 loader : OudsButtonLoader ? = null,
255268 appearance : OudsButtonAppearance = OudsButtonDefaults .Appearance ,
269+ iconOnlyBadge : OudsButtonIconBadge ? = null,
256270 interactionSource : MutableInteractionSource ? = null
257271) {
258272 val icon = nullableIcon
@@ -326,23 +340,78 @@ private fun OudsButton(
326340 }
327341
328342 val alpha = if (state == OudsButtonState .Loading ) 0f else 1f
343+ val paddingValues = contentPadding(icon = icon, label = label)
329344 Row (
330345 modifier = Modifier
331346 .alpha(alpha = alpha)
332- .padding(contentPadding(icon = icon, label = label) ),
347+ .padding(paddingValues ),
333348 horizontalArrangement = Arrangement .spacedBy(buttonTokens.spaceColumnGapIcon.value),
334349 verticalAlignment = Alignment .CenterVertically
335350 ) {
336351 if (icon != null ) {
337352 val size = if (label == null ) buttonTokens.sizeIconOnly else buttonTokens.sizeIcon
338- icon.Content (
339- modifier = Modifier
340- .size(size.value * iconScale)
341- .semantics {
342- contentDescription = if (label == null ) icon.contentDescription else " "
343- },
344- extraParameters = OudsButtonIcon .ExtraParameters (tint = contentColor.value)
345- )
353+ Box (modifier = Modifier .size(size.value * iconScale)) {
354+ icon.Content (
355+ modifier = Modifier
356+ .fillMaxSize()
357+ .semantics {
358+ contentDescription = if (label == null ) icon.contentDescription else " "
359+ },
360+ extraParameters = OudsButtonIcon .ExtraParameters (tint = contentColor.value)
361+ )
362+ if (label == null ) {
363+ val buttonEndPadding = paddingValues.calculateEndPadding(LocalLayoutDirection .current)
364+ val availableIconEndPadding = with (LocalDensity .current) {
365+ val iconBadgeEndPadding = 7 .dp// OudsTheme.spaces.paddingInline.fourExtraSmall
366+ (buttonEndPadding - borderWidth.value.orElse { 0 .dp } - iconBadgeEndPadding).toPx()
367+ }
368+ var iconBadgeSize by remember { mutableStateOf(IntSize .Zero ) }
369+ iconOnlyBadge?.Content (
370+ modifier = Modifier
371+ .requiredWidth(IntrinsicSize .Min ) // This allows to make the badge bigger than it's parent box if needed
372+ .onSizeChanged { iconBadgeSize = it }
373+ .align { _, parentSize, layoutDirection ->
374+ // We should use the first IntSize parameter of the align lambda to retrieve the badge size
375+ // but the value is incorrect and coerced to the parent size when the badge is bigger than it's parent
376+ // That is why we retrieve the icon badge size using the onSizeChanged modifier above
377+ if (iconOnlyBadge.count != null ) {
378+ val xOffset = when {
379+ // Important: hypothesis for the calculations below is that the box content alignment is equal to Alignment.TopStart
380+ // Note: parentSize is equal to icon size
381+ //
382+ // 1. Badge has enough space to be displayed at it's expected location
383+ // i.e. start at the horizontal center of the icon and bottom at the vertical center of the icon
384+ iconBadgeSize.width < parentSize.width / 2 + availableIconEndPadding -> if (layoutDirection == LayoutDirection .Ltr ) {
385+ parentSize.width / 2
386+ } else {
387+ parentSize.width / 2 - iconBadgeSize.width
388+ }
389+ // 2. Badge does not have enough space to be displayed at it's expected location
390+ // There are two cases:
391+ // 2.1. Badge width is bigger than the icon width
392+ // In that case Compose layouts the badge so that the horizontal center of the badge is the same as the horizontal center of the icon
393+ // i.e. The initial offset of the badge is equal to (parentSize.width - iconBadgeSize.width) / 2 in LTR
394+ iconBadgeSize.width > parentSize.width -> if (layoutDirection == LayoutDirection .Ltr ) {
395+ (parentSize.width - iconBadgeSize.width) / 2 + availableIconEndPadding.toInt()
396+ } else {
397+ (iconBadgeSize.width - parentSize.width) / 2 - availableIconEndPadding.toInt()
398+ }
399+ // 2.2. Badge width is smaller than the icon width
400+ // In that case Compose layouts the badge so that it starts at the start of the icon
401+ else -> if (layoutDirection == LayoutDirection .Ltr ) {
402+ parentSize.width + availableIconEndPadding.toInt() - iconBadgeSize.width
403+ } else {
404+ - availableIconEndPadding.toInt()
405+ }
406+ }
407+ IntOffset (x = xOffset, y = parentSize.height / 2 - iconBadgeSize.height)
408+ } else {
409+ IntOffset (x = parentSize.width - iconBadgeSize.width, y = 0 )
410+ }
411+ }
412+ )
413+ }
414+ }
346415 }
347416 if (label != null ) {
348417 Text (
@@ -724,6 +793,15 @@ enum class OudsButtonAppearance {
724793 */
725794data class OudsButtonLoader (val progress : Float? )
726795
796+ internal class OudsButtonIconBadge (contentDescription : String , borderColor : Color , count : Int? = null ) : OudsComponentIconBadge(contentDescription, count) {
797+
798+ private val _borderColor = borderColor
799+
800+ override val borderColor: Color
801+ @Composable
802+ get() = _borderColor
803+ }
804+
727805internal enum class OudsButtonState {
728806 Enabled , Hovered , Pressed , Loading , Disabled , Focused
729807}
@@ -746,7 +824,12 @@ internal fun PreviewOudsButton(
746824 val icon = if (hasIcon) OudsButtonIcon (Icons .Filled .FavoriteBorder , " " ) else null
747825 val content: @Composable () -> Unit = {
748826 PreviewEnumEntries <OudsButtonState >(columnCount = 2 ) {
749- OudsButton (nullableIcon = icon, nullableLabel = label, onClick = {}, appearance = appearance)
827+ OudsButton (
828+ nullableIcon = icon,
829+ nullableLabel = label,
830+ onClick = {},
831+ appearance = appearance
832+ )
750833 }
751834 }
752835 if (onColoredBox) {
@@ -768,7 +851,7 @@ private fun PreviewOudsButtonWithRoundedCorners() = PreviewOudsButtonWithRounded
768851internal fun PreviewOudsButtonWithRoundedCorners (theme : OudsThemeContract ) =
769852 OudsPreview (theme = theme.mapSettings { it.copy(roundedCornerButtons = true ) }) {
770853 val appearance = OudsButtonAppearance .Default
771- PreviewEnumEntries <OudsButtonState >(columnCount = 2 ) { state ->
854+ PreviewEnumEntries <OudsButtonState >(columnCount = 2 ) {
772855 OudsButton (
773856 nullableIcon = OudsButtonIcon (Icons .Filled .FavoriteBorder , " " ),
774857 nullableLabel = appearance.name,
@@ -778,6 +861,26 @@ internal fun PreviewOudsButtonWithRoundedCorners(theme: OudsThemeContract) =
778861 }
779862 }
780863
864+ @Preview
865+ @Composable
866+ @Suppress(" PreviewShouldNotBeCalledRecursively" )
867+ private fun PreviewOudsButtonWithIconBadge (@PreviewParameter(OudsButtonWithIconBadgePreviewParameterProvider ::class ) count : Int ) =
868+ PreviewOudsButtonWithIconBadge (theme = getPreviewTheme(), count = count)
869+
870+ @Composable
871+ internal fun PreviewOudsButtonWithIconBadge (theme : OudsThemeContract , count : Int ) = OudsPreview (theme = theme) {
872+ val appearance = OudsButtonAppearance .Default
873+ PreviewEnumEntries <OudsButtonState >(columnCount = 2 ) {
874+ OudsButton (
875+ nullableIcon = OudsButtonIcon (Icons .Filled .FavoriteBorder , " " ),
876+ nullableLabel = null ,
877+ onClick = {},
878+ appearance = appearance,
879+ iconOnlyBadge = OudsButtonIconBadge (" " , OudsTheme .componentsTokens.bar.colorBorderBadge.value, count = count)
880+ )
881+ }
882+ }
883+
781884internal data class OudsButtonPreviewParameter (
782885 val appearance : OudsButtonAppearance ,
783886 val hasLabel : Boolean ,
@@ -799,3 +902,5 @@ private val previewParameterValues: List<OudsButtonPreviewParameter>
799902 addAll(parameters.map { it.copy(onColoredBox = true ) })
800903 }
801904 }
905+
906+ internal class OudsButtonWithIconBadgePreviewParameterProvider : BasicPreviewParameterProvider <Int >(1 , OudsBadgeMaxCount + 1 )
0 commit comments