Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add repeatable clickable modifier and button #1646

Merged
merged 7 commits into from
Sep 6, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions composables/api/current.api
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ package com.google.android.horologist.composables {
property public final float weight;
}

public final class RepeatableClickableButtonKt {
method @androidx.compose.runtime.Composable @com.google.android.horologist.annotations.ExperimentalHorologistApi public static void RepeatableClickableButton(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, kotlin.jvm.functions.Function0<kotlin.Unit> onRepeatedClick, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional androidx.wear.compose.material.ButtonColors colors, optional androidx.compose.foundation.interaction.MutableInteractionSource interactionSource, optional androidx.compose.ui.graphics.Shape shape, optional androidx.wear.compose.material.ButtonBorder border, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.BoxScope,kotlin.Unit> content);
}

public final class RepeatableClickableKt {
method public static androidx.compose.ui.Modifier repeatableClickable(androidx.compose.ui.Modifier, androidx.compose.foundation.interaction.MutableInteractionSource interactionSource, androidx.compose.foundation.Indication? indication, optional boolean enabled, optional String? onClickLabel, optional androidx.compose.ui.semantics.Role? role, optional long initialDelay, optional long incrementalDelay, kotlin.jvm.functions.Function0<kotlin.Unit> onClick, optional kotlin.jvm.functions.Function0<kotlin.Unit> onRepeatableClick);
}

@com.google.android.horologist.annotations.ExperimentalHorologistApi public final class Section<T> {
ctor public Section(com.google.android.horologist.composables.Section.State<? extends T> state, optional kotlin.jvm.functions.Function1<? super com.google.android.horologist.composables.SectionContentScope,kotlin.Unit>? headerContent, optional com.google.android.horologist.composables.Section.VisibleStates headerVisibleStates, optional kotlin.jvm.functions.Function1<? super com.google.android.horologist.composables.SectionContentScope,kotlin.Unit>? loadingContent, optional int loadingContentCount, optional kotlin.jvm.functions.Function2<? super com.google.android.horologist.composables.SectionContentScope,? super T,kotlin.Unit>? loadedContent, optional kotlin.jvm.functions.Function1<? super com.google.android.horologist.composables.SectionContentScope,kotlin.Unit>? failedContent, optional kotlin.jvm.functions.Function1<? super com.google.android.horologist.composables.SectionContentScope,kotlin.Unit>? emptyContent, optional kotlin.jvm.functions.Function1<? super com.google.android.horologist.composables.SectionContentScope,kotlin.Unit>? footerContent, optional com.google.android.horologist.composables.Section.VisibleStates footerVisibleStates);
method public com.google.android.horologist.composables.Section.State<T> component1();
Expand Down
1 change: 1 addition & 0 deletions composables/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ dependencies {
implementation(libs.wearcompose.foundation)
implementation(libs.compose.material.iconscore)
implementation(libs.compose.material.iconsext)
implementation(libs.compose.material.ripple)
implementation(libs.compose.ui.util)
implementation(libs.androidx.corektx)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.google.android.horologist.composables

import androidx.compose.foundation.Indication
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.semantics.Role
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

/**
* This modifier provides functionality to increment or decrement values repeatedly by holding down
* the composable. Should be used instead of clickable modifier to achieve clickable and repeatable
* clickable behavior. Can't be used along with clickable modifier as it already implements it.
*
* Code from https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:wear/compose/compose-material-core/src/main/java/androidx/wear/compose/materialcore/RepeatableClickable.kt
*
* Callbacks [onClick] and [onRepeatableClick] are different. [onClick] is triggered only when the
* hold duration is shorter than [initialDelay] and no repeatable clicks happened.
* [onRepeatableClick] is repeatedly triggered when the hold duration is longer than [initialDelay]
* with [incrementalDelay] intervals.
*
* @param interactionSource [MutableInteractionSource] that will be used to dispatch
* [PressInteraction.Press] when this clickable is pressed. Only the initial (first) press will be
* recorded and dispatched with [MutableInteractionSource].
* @param indication indication to be shown when modified element is pressed. By default, indication
* from [LocalIndication] will be used. Pass `null` to show no indication, or current value from
* [LocalIndication] to show theme default
* @param enabled Controls the enabled state. When `false`, [onClick], and this modifier will appear
* disabled for accessibility services
* @param onClickLabel semantic / accessibility label for the [onClick] action
* @param role the type of user interface element. Accessibility services might use this to describe
* the element or do customizations
* @param initialDelay The initial delay before the click starts repeating, in ms
* @param incrementalDelay The delay between each repeated click, in ms
* @param onClick will be called when user clicks on the element
* @param onRepeatableClick will be called after the [initialDelay] with [incrementalDelay] between
* each call until the touch is released
*/
public fun Modifier.repeatableClickable(
interactionSource: MutableInteractionSource,
indication: Indication?,
enabled: Boolean = true,
onClickLabel: String? = null,
role: Role? = null,
initialDelay: Long = 500L,
incrementalDelay: Long = 60L,
onClick: () -> Unit,
onRepeatableClick: () -> Unit = onClick,
): Modifier = composed {
val currentOnRepeatableClick by rememberUpdatedState(onRepeatableClick)
val currentOnClick by rememberUpdatedState(onClick)
// This flag is used for checking whether the onClick should be ignored or not.
// If this flag is true, then it means that repeatable click happened and onClick
// shouldn't be triggered.
var ignoreOnClick by remember { mutableStateOf(false) }
// Repeatable logic should always follow the clickable, as the lowest modifier finishes first,
// and we have to be sure that repeatable goes before clickable.
clickable(
interactionSource = interactionSource,
indication = indication,
enabled = enabled,
onClickLabel = onClickLabel,
role = role,
onClick = {
if (!ignoreOnClick) {
currentOnClick()
}
ignoreOnClick = false
},
)
.pointerInput(enabled) {
coroutineScope {
awaitEachGesture {
awaitFirstDown()
ignoreOnClick = false
val repeatingJob = launch {
delay(initialDelay)
ignoreOnClick = true
while (enabled) {
currentOnRepeatableClick()
delay(incrementalDelay)
}
}
// Waiting for up or cancellation of the gesture.
waitForUpOrCancellation()
repeatingJob.cancel()
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.google.android.horologist.composables

import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.ripple.rememberRipple
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.draw.clip
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.semantics.Role
import androidx.wear.compose.material.ButtonBorder
import androidx.wear.compose.material.ButtonColors
import androidx.wear.compose.material.ButtonDefaults
import androidx.wear.compose.material.LocalContentAlpha
import androidx.wear.compose.material.LocalContentColor
import androidx.wear.compose.material.LocalTextStyle
import androidx.wear.compose.material.MaterialTheme
import com.google.android.horologist.annotations.ExperimentalHorologistApi

/**
* A base button that can send single onClick event or repeated [onRepeatedClick] events by
* holding it down.
*
* Code modified from https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:wear/compose/compose-material/src/main/java/androidx/wear/compose/material/Button.kt
*
*/
@ExperimentalHorologistApi
@Composable
public fun RepeatableClickableButton(
onClick: () -> Unit,
onRepeatedClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
colors: ButtonColors = ButtonDefaults.primaryButtonColors(),
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
shape: Shape = CircleShape,
border: ButtonBorder = ButtonDefaults.buttonBorder(),
content: @Composable BoxScope.() -> Unit,
) {
val borderStroke = border.borderStroke(enabled = enabled).value

Box(
contentAlignment = Alignment.Center,
modifier =
modifier
.defaultMinSize(
minWidth = ButtonDefaults.DefaultButtonSize,
minHeight = ButtonDefaults.DefaultButtonSize,
)
.then(
if (borderStroke != null) {
Modifier.border(border = borderStroke, shape = shape)
} else {
Modifier
},
)
.clip(shape)
.repeatableClickable(
enabled = enabled,
role = Role.Button,
indication = rememberRipple(),
onClick = onClick,
onRepeatableClick = onRepeatedClick,
interactionSource = interactionSource,
)
.background(color = colors.backgroundColor(enabled = enabled).value, shape = shape),
) {
val contentColor = colors.contentColor(enabled = enabled).value
CompositionLocalProvider(
LocalContentColor provides contentColor,
LocalContentAlpha provides contentColor.alpha,
LocalTextStyle provides MaterialTheme.typography.button,
) {
content()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.google.android.horologist.composables

import androidx.compose.foundation.layout.Box
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTouchInput
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.wear.compose.material.Text
import com.google.common.truth.Truth.assertThat
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class RepeatableClickableButtonTest {
@get:Rule
public val rule = createComposeRule()
private val text = "myRepeatableClickableButton"
private val onClick: () -> Unit = { ++clickCounter }
private val onRepeatedClick: () -> Unit = { ++repeatedClickCounter }

private var clickCounter = 0
private var repeatedClickCounter = 0

@Before
fun setup() {
rule.setContent {
Box {
RepeatableClickableButton(onClick = onClick, onRepeatedClick = onRepeatedClick) {
Text(text)
}
}
}

// Reset counters
clickCounter = 0
repeatedClickCounter = 0
}

@Test
fun findByTextAndClick() {
rule.onNodeWithText(text).performClick()

rule.runOnIdle {
assertThat(clickCounter).isEqualTo(1)
assertThat(repeatedClickCounter).isEqualTo(0)
}
}

@Test
fun findByTextAndHoldClick2Seconds() {
rule.onNodeWithText(text).performTouchInput {
down(center)
advanceEventTime(2000L)
up()
}

rule.runOnIdle {
assertThat(clickCounter).isEqualTo(0)
assertThat(repeatedClickCounter).isGreaterThan(0)
}
}
}
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ compose-foundation-foundation = { module = "androidx.compose.foundation:foundati
compose-foundation-foundation-layout = { module = "androidx.compose.foundation:foundation-layout", version.ref = "androidx-compose-material" }
compose-material-iconscore = { module = "androidx.compose.material:material-icons-core", version.ref = "androidx-compose-material" }
compose-material-iconsext = { module = "androidx.compose.material:material-icons-extended", version.ref = "androidx-compose-material" }
compose-material-ripple = { module = "androidx.compose.material:material-ripple", version.ref = "androidx-compose-material" }
compose-material3 = { module = "androidx.compose.material3:material3" }
compose-runtime = { module = "androidx.compose.runtime:runtime", version.ref = "compose" }
compose-ui = { module = "androidx.compose.ui:ui", version.ref = "compose" }
Expand Down