Skip to content

Instantly share code, notes, and snippets.

@projectdelta6
Last active May 20, 2025 11:18
Show Gist options
  • Save projectdelta6/5bad612e69f61ccf8d3a519c16973293 to your computer and use it in GitHub Desktop.
Save projectdelta6/5bad612e69f61ccf8d3a519c16973293 to your computer and use it in GitHub Desktop.
An Android AppCompatTextView with a touch listener that repeats an actoin on an interval while the view is touched and stops when it is released
//package your.package.here
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
internal const val INITIAL_INTERVAL_MS = 400L // Initial delay between clicks
internal const val SUBSEQUENT_INTERVAL_MS = 200L // Delay after initial events
internal const val INITIAL_EVENT_COUNT = 3 // Number of events before switching interval
/**
* A modifier that allows for a regulated touch event, where the onClick action is triggered
* repeatedly at a specified interval while the user is touching the screen.
*
* This is useful for creating buttons that can be held down to perform an action repeatedly,
* such as incrementing or decrementing a value.
*
* @param onClick The action to perform when the button is clicked.
* @param enabled Whether the touch event is enabled or not.
* @param initialInterval The initial delay between clicks in milliseconds.
* @param subsequentInterval The delay after the initial events in milliseconds.
* @param initialEventCount The number of events before switching to the subsequent interval.
*/
fun Modifier.regulatedTouch(
onClick: () -> Unit,
enabled: Boolean = true,
initialInterval: Long = INITIAL_INTERVAL_MS,
subsequentInterval: Long = SUBSEQUENT_INTERVAL_MS,
initialEventCount: Int = INITIAL_EVENT_COUNT
): Modifier = composed {
if (!enabled) return@composed this
var clickJob by remember { mutableStateOf<Job?>(null) }
var clickCount by remember { mutableIntStateOf(0) }
val scope = rememberCoroutineScope()
this
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
// Wait for the first touch
val down = awaitPointerEvent(pass = PointerEventPass.Main)
if (down.changes.any { it.pressed }) {
clickCount = 0
clickJob?.cancel() // Cancel any existing job
// Start repeating clicks
clickJob = scope.launch {
while (true) {
onClick()
clickCount++
val interval = if (clickCount < initialEventCount) {
initialInterval
} else {
subsequentInterval
}
delay(interval)
}
}
}
// Wait for touch release
while (true) {
val event = awaitPointerEvent(pass = PointerEventPass.Main)
if (event.changes.none { it.pressed }) {
clickJob?.cancel()
clickJob = null
break
}
// Consume all events to prevent parent interception
event.changes.forEach { it.consume() }
}
}
}
}
}
@Composable
fun PlusMinusControl(
modifier: Modifier = Modifier,
value: String,
onInc: () -> Unit,
onDec: () -> Unit,
textStyle: TextStyle = LocalTextStyle.current,
decrementContent: @Composable BoxScope.() -> Unit = {
Text(
modifier = Modifier
.size(20.dp),
text = "-",
style = textStyle
)
},
incrementContent: @Composable BoxScope.() -> Unit = {
Text(
modifier = Modifier
.size(20.dp),
text = "+",
style = textStyle
)
},
enabled: Boolean = true,
initialInterval: Long = INITIAL_INTERVAL_MS,
subsequentInterval: Long = SUBSEQUENT_INTERVAL_MS,
initialEventCount: Int = INITIAL_EVENT_COUNT,
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.Center
) {
Box(
modifier = Modifier
.regulatedTouch(
onClick = onDec,
enabled = enabled,
initialInterval = initialInterval,
subsequentInterval = subsequentInterval,
initialEventCount = initialEventCount
),
content = decrementContent
)
Text(
modifier = Modifier
.weight(1f),
text = value,
style = textStyle
)
Box(
modifier = Modifier
.regulatedTouch(
onClick = onInc,
enabled = enabled,
initialInterval = initialInterval,
subsequentInterval = subsequentInterval,
initialEventCount = initialEventCount
),
content = incrementContent
)
}
}
@Preview
@Composable
fun PreviewRegulatedTouch() {
Surface(
modifier = Modifier.fillMaxWidth(),
) {
var duration by remember { mutableStateOf(Duration.ZERO) }
Column(
modifier = Modifier
.fillMaxWidth(),
) {
PlusMinusControl(
modifier = Modifier
.padding(horizontal = 40.dp, vertical = 40.dp)
.fillMaxWidth(),
value = duration.toString(),
onInc = {
duration += 15.minutes
},
onDec = {
duration -= 15.minutes
},
textStyle = LocalTextStyle.current.copy(
textAlign = TextAlign.Center,
)
)
}
}
}
//package your.package.here
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import android.view.View.OnTouchListener
import androidx.appcompat.widget.AppCompatTextView
/**
* Created by Bradley Duck on 2018/04/24.
*/
class RegulatedTouchTextView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = android.R.attr.textViewStyle
) : AppCompatTextView(context, attrs, defStyleAttr), OnTouchListener {
var v: View? = null
var event: MotionEvent? = null
private val mHandler: Handler = Handler(Looper.getMainLooper())
private var mTouchListener: RegulatedOnTouchListener? = null
private var mInitialInterval = 500
private var mSecondInterval = 200
private var doneInitial = false
private var running = false
/**
* [Runnable] to regulate the repeating touch events, i.e. 'hold' action.
* The runnable executes immediately and then schedules a repeat with a longer delay for the first repeat and then with a shorter delay for the following repeats.
*
* See [RegulatedTouchTextView.setInitialInterval] to set the first longer delay.
*
* See [RegulatedTouchTextView.setSecondInterval] to set the follow-on shorter delay.
*/
private val delayedTouch: Runnable = object : Runnable {
override fun run() {
try {
if (mTouchListener != null && v != null && event != null) {
mTouchListener!!.regulatedOnTouch(v, event)
}
} finally {
mHandler.removeCallbacks(this)
if (!doneInitial) {
doneInitial = true
mHandler.postDelayed(this, mInitialInterval.toLong())
} else {
mHandler.postDelayed(this, mSecondInterval.toLong())
}
}
}
}
/**
* Sets the InitialInterval for this view's RegulatedOnTouchListener to use.
*
*
* The InitialInterval is the delay between the first touch event and the second (with the user 'Holding down' the touch throughout).
*
* @param milliseconds The delay in milliseconds.
*/
fun setInitialInterval(milliseconds: Int) {
mInitialInterval = milliseconds
}
/**
* Sets the SecondInterval for this view's RegulatedOnTouchListener to use.
*
*
* The SecondInterval is the delay between the second and third, and all following events thereafter, until the user stops 'touching' this view.
*
* @param milliseconds The delay in milliseconds.
*/
fun setSecondInterval(milliseconds: Int) {
mSecondInterval = milliseconds
}
/**
* Sets the [RegulatedOnTouchListener] for this view.
*
* @param regulatedOnTouchListener The [RegulatedOnTouchListener] to be set.
*/
fun setRegulatedOnTouchListener(regulatedOnTouchListener: RegulatedOnTouchListener) {
mTouchListener = regulatedOnTouchListener
setOnTouchListener(this)
}
/**
* Removes the [RegulatedOnTouchListener] associated with this view.
*/
fun clearRegulatedOnTouchListener() {
mTouchListener = null
}
/**
* Called when a touch event is dispatched to a view. This allows listeners to
* get a chance to respond before the target view.
*
* @param v The view the touch event has been dispatched to.
* @param event The MotionEvent object containing full information about
* the event.
* @return True if the listener has consumed the event, false otherwise.
*/
override fun onTouch(v: View, event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
// when the user touches his/her finger down.
if (!running) {
start(v, event)
}
return true
}
MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> {
// when the user releases his/her finger.
if (running) {
stop()
}
return true
}
}
return false
}
/**
* Starts the [RegulatedOnTouchListener] repetition and sets the [View] and [MotionEvent] to be passed along.
*
* @param v The view the touch event has been dispatched to.
* @param event The MotionEvent object containing full information about the event.
*/
private fun start(v: View, event: MotionEvent) {
this.v = v
this.event = event
running = true
delayedTouch.run()
}
/**
* Stops the [RegulatedOnTouchListener] repetition. and clears the [View] and [MotionEvent].
*/
private fun stop() {
mHandler.removeCallbacks(delayedTouch)
running = false
doneInitial = false
v = null
event = null
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
stop() // stop the runnable
}
/**
* Interface definition for a callback to be invoked when a RegulatedTouch event is
* dispatched to this view.
*/
interface RegulatedOnTouchListener {
/**
* Called repeatedly at an adjustable interval starting when an [android.view.View.OnTouchListener]
* event with [MotionEvent.ACTION_DOWN] is received on this view and stopped when
* [android.view.View.OnTouchListener] event with either [MotionEvent.ACTION_UP] or
* [MotionEvent.ACTION_CANCEL] is received on this view.
*
* @param v The view the touch event has been dispatched to.
* @param event The MotionEvent object containing full information about the event.
*/
fun regulatedOnTouch(v: View?, event: MotionEvent?)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment