Last active
May 20, 2025 11:18
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
//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, | |
) | |
) | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
//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