Bug 1949514 - Update IconButton and LongPressIconButton with haptics support r=android-reviewers,tchoh

Overcome missing support from Compose upstream.

Differential Revision: https://phabricator.services.mozilla.com/D239359
This commit is contained in:
Mugurell
2025-03-25 19:08:55 +00:00
parent 73818dd05a
commit d3e075161a
6 changed files with 287 additions and 111 deletions

View File

@@ -0,0 +1,128 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package mozilla.components.compose.base.button
import android.view.SoundEffectConstants
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.Interaction
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.material.ContentAlpha
import androidx.compose.material.Icon
import androidx.compose.material.LocalContentAlpha
import androidx.compose.material.Text
import androidx.compose.material.minimumInteractiveComponentSize
import androidx.compose.material.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import mozilla.components.compose.base.annotation.LightDarkPreview
import mozilla.components.compose.base.theme.AcornTheme
import mozilla.components.ui.icons.R as iconsR
// Temporary workaround to Compose buttons not having click sounds
// see https://issuetracker.google.com/issues/218064821
private val RippleRadius = 24.dp
/**
* A Button with the following functionalities:
* - it has a minimum touch target size of 48dp
* - it will play a sound effect for clicks
* - it will use the [AcornTheme] ripple color.
*
* @param onClick Callback for when this button is clicked.
* @param contentDescription Text used by accessibility services to describe what this button does.
* @param modifier Optional modifier for further customisation of this button.
* @param onClickLabel Semantic / accessibility label for the [onClick] action.
* Will be read as "Double tap to [onClickLabel]".
* @param enabled Whether or not this button will handle input events and appear enabled
* for semantics purposes. `true` by default.
* @param interactionSource An optional hoisted [MutableInteractionSource] for observing and
* emitting [Interaction]s for this button. You can use this to change the button's appearance
* or preview the button in different states. Note that if `null` is provided interactions will
* still happen internally.
* @param content The content to be shown inside this button.
*/
@Composable
fun IconButton(
onClick: () -> Unit,
contentDescription: String,
modifier: Modifier = Modifier,
onClickLabel: String? = null,
enabled: Boolean = true,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
content: @Composable () -> Unit,
) {
val view = LocalView.current
Box(
modifier = modifier
.semantics { this.contentDescription = contentDescription }
.minimumInteractiveComponentSize()
.clickable(
interactionSource = interactionSource,
indication = ripple(
bounded = false,
radius = RippleRadius,
color = AcornTheme.colors.ripple,
),
enabled = enabled,
onClickLabel = onClickLabel,
role = Role.Button,
onClick = {
view.playSoundEffect(SoundEffectConstants.CLICK)
onClick()
},
),
contentAlignment = Alignment.Center,
) {
val contentAlpha = if (enabled) LocalContentAlpha.current else ContentAlpha.disabled
CompositionLocalProvider(LocalContentAlpha provides contentAlpha, content = content)
}
}
@LightDarkPreview
@Composable
private fun IconButtonPreview() {
AcornTheme {
IconButton(
onClick = {},
contentDescription = "test",
modifier = Modifier.background(AcornTheme.colors.layer1),
) {
Icon(
painter = painterResource(iconsR.drawable.mozac_ic_bookmark_fill_24),
contentDescription = null,
tint = AcornTheme.colors.iconButton,
)
}
}
}
@LightDarkPreview
@Composable
private fun TextButtonPreview() {
AcornTheme {
IconButton(
onClick = {},
contentDescription = "test",
modifier = Modifier.background(AcornTheme.colors.layer1),
) {
Text(
text = "button",
color = AcornTheme.colors.textPrimary,
)
}
}
}

View File

@@ -0,0 +1,151 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package mozilla.components.compose.base.button
import android.view.SoundEffectConstants
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.Interaction
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.material.ContentAlpha
import androidx.compose.material.Icon
import androidx.compose.material.LocalContentAlpha
import androidx.compose.material.Text
import androidx.compose.material.minimumInteractiveComponentSize
import androidx.compose.material.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import mozilla.components.compose.base.annotation.LightDarkPreview
import mozilla.components.compose.base.modifier.rightClickable
import mozilla.components.compose.base.theme.AcornTheme
import mozilla.components.ui.icons.R as iconsR
// Temporary workaround to Compose buttons not having click sounds
// see https://issuetracker.google.com/issues/219984415
private val RippleRadius = 24.dp
/**
* A button with the following functionalities:
* - it has a minimum touch target size of 48dp
* - it will perform a haptic feedback for long clicks or right clicks
* - it will play a sound effect for clicks
* - it will use the [AcornTheme] ripple color.
*
* @param onClick Callback for when this button is clicked.
* @param onLongClick Callback for when this button is long clicked or right click.
* @param contentDescription Text used by accessibility services to describe what this button does.
* @param modifier Optional modifier for further customisation of this button.
* @param onClickLabel Semantic / accessibility label for the [onClick] action.
* Will be read as "Double tap to [onLongClick]". Leave `null` for "activate" to be read.
* @param onLongClickLabel Semantic / accessibility label for the [onLongClick] action.
* Will be read as "Double tap and hold to [onLongClickLabel]". Leave `null` for "long press" to be read.
* @param enabled Whether or not this button will handle input events and appear enabled
* for semantics purposes. `true` by default.
* @param interactionSource An optional hoisted [MutableInteractionSource] for observing and
* emitting [Interaction]s for this button. You can use this to change the button's appearance
* or preview the button in different states. Note that if `null` is provided, interactions will
* still happen internally.
* @param content The content to be shown inside this button.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun LongPressIconButton(
onClick: () -> Unit,
onLongClick: (() -> Unit),
contentDescription: String,
modifier: Modifier = Modifier,
onClickLabel: String? = null,
onLongClickLabel: String? = null,
enabled: Boolean = true,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
content: @Composable () -> Unit,
) {
val haptic = LocalHapticFeedback.current
val view = LocalView.current
Box(
modifier = modifier
.semantics { this.contentDescription = contentDescription }
.minimumInteractiveComponentSize()
.combinedClickable(
interactionSource = interactionSource,
indication = ripple(bounded = false, radius = RippleRadius, color = AcornTheme.colors.ripple),
enabled = enabled,
onClickLabel = onClickLabel,
role = Role.Button,
onLongClickLabel = onLongClickLabel,
onLongClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onLongClick()
},
onClick = {
view.playSoundEffect(SoundEffectConstants.CLICK)
onClick()
},
)
.rightClickable(
interactionSource = interactionSource,
indication = ripple(bounded = false, radius = RippleRadius, color = AcornTheme.colors.ripple),
onRightClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onLongClick()
},
),
contentAlignment = Alignment.Center,
) {
val contentAlpha = if (enabled) LocalContentAlpha.current else ContentAlpha.disabled
CompositionLocalProvider(LocalContentAlpha provides contentAlpha, content = content)
}
}
@LightDarkPreview
@Composable
private fun LongPressIconButtonPreview() {
AcornTheme {
LongPressIconButton(
onClick = {},
onLongClick = {},
contentDescription = "test",
modifier = Modifier.background(AcornTheme.colors.layer1),
) {
Icon(
painter = painterResource(iconsR.drawable.mozac_ic_bookmark_fill_24),
contentDescription = null,
tint = AcornTheme.colors.iconButton,
)
}
}
}
@LightDarkPreview
@Composable
private fun LongPressTextButtonPreview() {
AcornTheme {
LongPressIconButton(
onClick = {},
onLongClick = {},
contentDescription = "test",
modifier = Modifier.background(AcornTheme.colors.layer1),
) {
Text(
text = "button",
color = AcornTheme.colors.textPrimary,
)
}
}
}

View File

@@ -25,7 +25,7 @@ import org.mozilla.samples.toolbar.middleware.SearchSelectorInteractions.Setting
import org.mozilla.samples.toolbar.middleware.SearchSelectorInteractions.TabsClicked import org.mozilla.samples.toolbar.middleware.SearchSelectorInteractions.TabsClicked
import mozilla.components.ui.icons.R as iconsR import mozilla.components.ui.icons.R as iconsR
sealed class SearchSelectorInteractions : BrowserToolbarEvent { private sealed class SearchSelectorInteractions : BrowserToolbarEvent {
data object BookmarksClicked : SearchSelectorInteractions() data object BookmarksClicked : SearchSelectorInteractions()
data object TabsClicked : SearchSelectorInteractions() data object TabsClicked : SearchSelectorInteractions()
data object HistoryClicked : SearchSelectorInteractions() data object HistoryClicked : SearchSelectorInteractions()

View File

@@ -40,6 +40,8 @@ import mozilla.components.browser.state.selector.selectedTab
import mozilla.components.browser.state.store.BrowserStore import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.compose.base.Divider import mozilla.components.compose.base.Divider
import mozilla.components.compose.base.annotation.LightDarkPreview import mozilla.components.compose.base.annotation.LightDarkPreview
import mozilla.components.compose.base.button.IconButton
import mozilla.components.compose.base.button.LongPressIconButton
import mozilla.components.lib.state.ext.observeAsComposableState import mozilla.components.lib.state.ext.observeAsComposableState
import mozilla.components.lib.state.ext.observeAsState import mozilla.components.lib.state.ext.observeAsState
import mozilla.components.ui.tabcounter.TabCounterMenu import mozilla.components.ui.tabcounter.TabCounterMenu
@@ -49,8 +51,6 @@ import org.mozilla.fenix.components.AppStore
import org.mozilla.fenix.components.appstate.AppState import org.mozilla.fenix.components.appstate.AppState
import org.mozilla.fenix.components.components import org.mozilla.fenix.components.components
import org.mozilla.fenix.components.toolbar.NewTabMenu import org.mozilla.fenix.components.toolbar.NewTabMenu
import org.mozilla.fenix.compose.IconButton
import org.mozilla.fenix.compose.LongPressIconButton
import org.mozilla.fenix.compose.utils.KeyboardState import org.mozilla.fenix.compose.utils.KeyboardState
import org.mozilla.fenix.compose.utils.keyboardAsState import org.mozilla.fenix.compose.utils.keyboardAsState
import org.mozilla.fenix.perf.MarkersFragmentLifecycleCallbacks import org.mozilla.fenix.perf.MarkersFragmentLifecycleCallbacks
@@ -405,6 +405,7 @@ private fun BackButton(
LongPressIconButton( LongPressIconButton(
onClick = onBackButtonClick, onClick = onBackButtonClick,
onLongClick = onBackButtonLongPress, onLongClick = onBackButtonLongPress,
contentDescription = stringResource(R.string.browser_menu_back),
enabled = enabled, enabled = enabled,
modifier = Modifier modifier = Modifier
.size(48.dp) .size(48.dp)
@@ -429,6 +430,7 @@ private fun ForwardButton(
LongPressIconButton( LongPressIconButton(
onClick = onForwardButtonClick, onClick = onForwardButtonClick,
onLongClick = onForwardButtonLongPress, onLongClick = onForwardButtonLongPress,
contentDescription = stringResource(R.string.browser_menu_forward),
enabled = enabled, enabled = enabled,
modifier = Modifier modifier = Modifier
.size(48.dp) .size(48.dp)
@@ -448,6 +450,7 @@ private fun SearchWebButton(
) { ) {
IconButton( IconButton(
onClick = onSearchButtonClick, onClick = onSearchButtonClick,
contentDescription = stringResource(R.string.search_hint),
modifier = Modifier modifier = Modifier
.testTag(NavBarTestTags.searchButton), .testTag(NavBarTestTags.searchButton),
) { ) {
@@ -469,6 +472,7 @@ private fun MenuButton(
if (isMenuRedesignEnabled) { if (isMenuRedesignEnabled) {
IconButton( IconButton(
onClick = onMenuButtonClick, onClick = onMenuButtonClick,
contentDescription = stringResource(R.string.content_description_menu),
modifier = Modifier modifier = Modifier
.size(48.dp) .size(48.dp)
.testTag(NavBarTestTags.menuButton), .testTag(NavBarTestTags.menuButton),
@@ -509,6 +513,7 @@ private fun OpenInBrowserButton(
) { ) {
IconButton( IconButton(
onClick = onOpenInBrowserButtonClick, onClick = onOpenInBrowserButtonClick,
contentDescription = stringResource(R.string.browser_menu_open_in_fenix, stringResource(R.string.app_name)),
enabled = enabled, enabled = enabled,
modifier = Modifier modifier = Modifier
.testTag(NavBarTestTags.openInBrowserButton), .testTag(NavBarTestTags.openInBrowserButton),

View File

@@ -1,51 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.compose
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.material.ContentAlpha
import androidx.compose.material.LocalContentAlpha
import androidx.compose.material.minimumInteractiveComponentSize
import androidx.compose.material.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import org.mozilla.fenix.theme.FirefoxTheme
/**
* An [androidx.compose.material.IconButton] that allows for setting the indication
* to be a ripple that matches the [FirefoxTheme]
*/
@Composable
fun IconButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
interactionSource: MutableInteractionSource? = null,
content: @Composable () -> Unit,
) {
Box(
modifier = modifier
.minimumInteractiveComponentSize()
.clickable(
onClick = onClick,
enabled = enabled,
role = Role.Button,
interactionSource = interactionSource,
indication = ripple(bounded = false, radius = RippleRadius, color = FirefoxTheme.colors.ripple),
),
contentAlignment = Alignment.Center,
) {
val contentAlpha = if (enabled) LocalContentAlpha.current else ContentAlpha.disabled
CompositionLocalProvider(LocalContentAlpha provides contentAlpha, content = content)
}
}
private val RippleRadius = 24.dp

View File

@@ -1,57 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.compose
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.material.ContentAlpha
import androidx.compose.material.IconButton
import androidx.compose.material.LocalContentAlpha
import androidx.compose.material.minimumInteractiveComponentSize
import androidx.compose.material.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import org.mozilla.fenix.theme.FirefoxTheme
/**
* An [IconButton] that supports a long press gesture.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun LongPressIconButton(
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
content: @Composable () -> Unit,
) {
Box(
modifier = modifier
.minimumInteractiveComponentSize()
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
enabled = enabled,
role = Role.Button,
interactionSource = interactionSource,
indication = ripple(bounded = false, radius = RippleRadius, color = FirefoxTheme.colors.ripple),
),
contentAlignment = Alignment.Center,
) {
val contentAlpha = if (enabled) LocalContentAlpha.current else ContentAlpha.disabled
CompositionLocalProvider(LocalContentAlpha provides contentAlpha, content = content)
}
}
private val RippleRadius = 24.dp