Skip to content

Commit f7a1110

Browse files
committed
Add OudsComponentIconBadge and an icon badge parameter in OudsButton
1 parent c869a2c commit f7a1110

17 files changed

+203
-48
lines changed

core/src/main/java/com/orange/ouds/core/component/OudsBadge.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import androidx.compose.ui.semantics.contentDescription
4242
import androidx.compose.ui.semantics.semantics
4343
import androidx.compose.ui.text.TextStyle
4444
import androidx.compose.ui.text.style.LineHeightStyle
45+
import androidx.compose.ui.text.style.TextOverflow
4546
import androidx.compose.ui.tooling.preview.Preview
4647
import androidx.compose.ui.tooling.preview.PreviewParameter
4748
import androidx.compose.ui.unit.Dp
@@ -254,7 +255,9 @@ private fun OudsBadge(
254255
modifier = Modifier.clearAndSetSemantics {},
255256
text = text,
256257
color = contentColor,
257-
style = textStyle
258+
style = textStyle,
259+
maxLines = 1,
260+
overflow = TextOverflow.Ellipsis
258261
)
259262
}
260263
}

core/src/main/java/com/orange/ouds/core/component/OudsButton.kt

Lines changed: 117 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,14 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
2020
import androidx.compose.foundation.isSystemInDarkTheme
2121
import androidx.compose.foundation.layout.Arrangement
2222
import androidx.compose.foundation.layout.Box
23+
import androidx.compose.foundation.layout.IntrinsicSize
2324
import androidx.compose.foundation.layout.PaddingValues
2425
import androidx.compose.foundation.layout.Row
26+
import androidx.compose.foundation.layout.calculateEndPadding
27+
import androidx.compose.foundation.layout.fillMaxSize
2528
import androidx.compose.foundation.layout.heightIn
2629
import androidx.compose.foundation.layout.padding
30+
import androidx.compose.foundation.layout.requiredWidth
2731
import androidx.compose.foundation.layout.size
2832
import androidx.compose.foundation.layout.widthIn
2933
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -32,15 +36,20 @@ import androidx.compose.material.icons.filled.FavoriteBorder
3236
import androidx.compose.material3.Text
3337
import androidx.compose.runtime.Composable
3438
import androidx.compose.runtime.getValue
39+
import androidx.compose.runtime.mutableStateOf
3540
import androidx.compose.runtime.remember
41+
import androidx.compose.runtime.setValue
3642
import androidx.compose.ui.Alignment
3743
import androidx.compose.ui.Modifier
3844
import androidx.compose.ui.draw.alpha
3945
import androidx.compose.ui.graphics.Color
4046
import androidx.compose.ui.graphics.ImageBitmap
4147
import androidx.compose.ui.graphics.painter.Painter
4248
import androidx.compose.ui.graphics.vector.ImageVector
49+
import androidx.compose.ui.layout.onSizeChanged
4350
import androidx.compose.ui.platform.LocalConfiguration
51+
import androidx.compose.ui.platform.LocalDensity
52+
import androidx.compose.ui.platform.LocalLayoutDirection
4453
import androidx.compose.ui.res.stringResource
4554
import androidx.compose.ui.semantics.Role
4655
import androidx.compose.ui.semantics.contentDescription
@@ -52,11 +61,15 @@ import androidx.compose.ui.tooling.preview.Preview
5261
import androidx.compose.ui.tooling.preview.PreviewLightDark
5362
import androidx.compose.ui.tooling.preview.PreviewParameter
5463
import 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
5567
import androidx.compose.ui.unit.dp
5668
import com.orange.ouds.core.R
5769
import com.orange.ouds.core.component.common.outerBorder
5870
import com.orange.ouds.core.component.content.OudsComponentContent
5971
import com.orange.ouds.core.component.content.OudsComponentIcon
72+
import com.orange.ouds.core.component.content.OudsComponentIconBadge
6073
import com.orange.ouds.core.extensions.InteractionState
6174
import com.orange.ouds.core.extensions.collectInteractionStateAsState
6275
import 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
*/
725794
data 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+
727805
internal 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
768851
internal 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+
781884
internal 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)

core/src/main/java/com/orange/ouds/core/component/OudsNavigationBar.kt

Lines changed: 17 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ import androidx.compose.foundation.layout.Row
2424
import androidx.compose.foundation.layout.RowScope
2525
import androidx.compose.foundation.layout.WindowInsets
2626
import androidx.compose.foundation.layout.offset
27-
import androidx.compose.foundation.layout.padding
2827
import androidx.compose.foundation.layout.size
2928
import androidx.compose.foundation.selection.selectable
3029
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -66,6 +65,7 @@ import androidx.constraintlayout.compose.ConstraintLayout
6665
import androidx.constraintlayout.compose.Dimension
6766
import com.orange.ouds.core.component.content.OudsComponentContent
6867
import com.orange.ouds.core.component.content.OudsComponentIcon
68+
import com.orange.ouds.core.component.content.OudsComponentIconBadge
6969
import com.orange.ouds.core.extensions.InteractionState
7070
import com.orange.ouds.core.extensions.collectInteractionStateAsState
7171
import com.orange.ouds.core.extensions.value
@@ -246,7 +246,18 @@ data class OudsNavigationBarItem(
246246
icon = {
247247
if (badge != null) {
248248
BadgedBox(
249-
badge = { badge.Content() },
249+
badge = {
250+
val badgeModifier = if (badge.count == null) {
251+
val startPosition = OudsNavigationBarItemIcon.Size / 2
252+
val badgeSize = OudsTheme.componentsTokens.badge.sizeXsmall.dp
253+
val xOffset = startPosition - badgeSize
254+
val yOffset = (startPosition - badgeSize) + 2.dp
255+
Modifier.offset(x = xOffset, y = -yOffset)
256+
} else {
257+
Modifier
258+
}
259+
badge.Content(modifier = badgeModifier)
260+
}
250261
) {
251262
icon.Content()
252263
}
@@ -380,33 +391,11 @@ class OudsNavigationBarItemIcon private constructor(
380391
* @property contentDescription Content description of the badge, needed for accessibility support (vocalized by Talkback).
381392
* @property count Optional number displayed in the badge. If not null, the badge has an [OudsBadgeSize.Medium] size. Otherwise, it has an [OudsBadgeSize.ExtraSmall] size.
382393
*/
383-
class OudsNavigationBarItemBadge(val contentDescription: String, val count: Int? = null) : OudsComponentContent<Nothing>(Nothing::class.java) {
394+
class OudsNavigationBarItemBadge(contentDescription: String, count: Int? = null) : OudsComponentIconBadge(contentDescription, count) {
384395

385-
@Composable
386-
override fun Content(modifier: Modifier) {
387-
val status = OudsBadgeStatus.Negative // In a navigation bar a badge has always a negative status.
388-
val positionModifier = if (count != null) {
389-
Modifier
390-
} else {
391-
val startPosition = OudsNavigationBarItemIcon.Size / 2
392-
val badgeSize = OudsTheme.componentsTokens.badge.sizeXsmall.dp
393-
val xOffset = startPosition - badgeSize
394-
val yOffset = (startPosition - badgeSize) + 2.dp
395-
Modifier.offset(x = xOffset, y = -yOffset)
396-
}
397-
398-
Box(
399-
modifier = positionModifier
400-
.background(color = OudsTheme.componentsTokens.bar.colorBorderBadge.value, shape = OudsBadgeShape)
401-
.padding(1.dp)
402-
) {
403-
count?.let {
404-
OudsBadge(count = count, modifier = modifier, status = status, size = OudsBadgeSize.Medium)
405-
}.orElse {
406-
OudsBadge(modifier = modifier, status = status, size = OudsBadgeSize.ExtraSmall)
407-
}
408-
}
409-
}
396+
override val borderColor: Color
397+
@Composable
398+
get() = OudsTheme.componentsTokens.bar.colorBorderBadge.value
410399
}
411400

412401
internal enum class OudsNavigationBarItemState {
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/*
2+
* Software Name: OUDS Android
3+
* SPDX-FileCopyrightText: Copyright (c) Orange SA
4+
* SPDX-License-Identifier: MIT
5+
*
6+
* This software is distributed under the MIT license,
7+
* the text of which is available at https://opensource.org/license/MIT/
8+
* or see the "LICENSE" file for more details.
9+
*
10+
* Software description: Android library of reusable graphical components
11+
*/
12+
13+
package com.orange.ouds.core.component.content
14+
15+
import androidx.compose.foundation.layout.Box
16+
import androidx.compose.runtime.Composable
17+
import androidx.compose.ui.Modifier
18+
import androidx.compose.ui.graphics.Color
19+
import androidx.compose.ui.semantics.contentDescription
20+
import androidx.compose.ui.semantics.semantics
21+
import androidx.compose.ui.unit.dp
22+
import com.orange.ouds.core.component.OudsBadge
23+
import com.orange.ouds.core.component.OudsBadgeShape
24+
import com.orange.ouds.core.component.OudsBadgeSize
25+
import com.orange.ouds.core.component.OudsBadgeStatus
26+
import com.orange.ouds.core.theme.outerBorder
27+
import com.orange.ouds.foundation.extensions.orElse
28+
29+
open class OudsComponentIconBadge internal constructor(val contentDescription: String, val count: Int?) : OudsComponentContent<Nothing>(Nothing::class.java) {
30+
31+
protected open val borderColor: Color
32+
@Composable
33+
get() = Color.Unspecified
34+
35+
@Composable
36+
override fun Content(modifier: Modifier) {
37+
Box(
38+
modifier = modifier
39+
.outerBorder(1.dp, color = borderColor, shape = OudsBadgeShape, innerOffsetPx = -1f)
40+
.semantics {
41+
contentDescription = this@OudsComponentIconBadge.contentDescription
42+
}
43+
) {
44+
val status = OudsBadgeStatus.Negative // A badge always has a negative status on an icon
45+
count?.let {
46+
OudsBadge(count = count, status = status, size = OudsBadgeSize.Medium)
47+
}.orElse {
48+
OudsBadge(status = status, size = OudsBadgeSize.ExtraSmall)
49+
}
50+
}
51+
}
52+
}

0 commit comments

Comments
 (0)