RepeatableClickable.kt
/*
* 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
*
* http://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 androidx.wear.compose.materialcore
import androidx.annotation.RestrictTo
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.
*
* 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
*/
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
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()
}
}
}
}