1 /* 2 * Copyright (C) 2023 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.systemui.haptics.slider 18 19 import androidx.annotation.VisibleForTesting 20 import kotlin.math.abs 21 import kotlinx.coroutines.CoroutineScope 22 import kotlinx.coroutines.Job 23 import kotlinx.coroutines.delay 24 import kotlinx.coroutines.isActive 25 import kotlinx.coroutines.launch 26 27 /** 28 * Slider tracker attached to a slider. 29 * 30 * The tracker runs a state machine to execute actions on touch-based events typical of a general 31 * slider (including a [android.widget.SeekBar]). Coroutines responsible for running the state 32 * machine, collecting slider events and maintaining waiting states are run on the provided 33 * [CoroutineScope]. 34 * 35 * @param[sliderStateListener] Listener of the slider state. 36 * @param[sliderEventProducer] Producer of slider events arising from the slider. 37 * @param[trackerScope] [CoroutineScope] used to launch coroutines for the collection of slider 38 * events and the launch of timer jobs. 39 * @property[config] Configuration parameters of the slider tracker. 40 */ 41 class SliderStateTracker( 42 sliderStateListener: SliderStateListener, 43 sliderEventProducer: SliderEventProducer, 44 trackerScope: CoroutineScope, 45 private val config: SeekableSliderTrackerConfig = SeekableSliderTrackerConfig(), 46 ) : SliderTracker(trackerScope, sliderStateListener, sliderEventProducer) { 47 48 // History of the latest progress collected from slider events 49 private var latestProgress = 0f 50 // Timer job for the wait state 51 private var timerJob: Job? = null 52 // Indicator that there is waiting job active 53 var isWaiting = false 54 private set 55 get() = timerJob != null && timerJob?.isActive == true 56 iterateStatenull57 override suspend fun iterateState(event: SliderEvent) { 58 when (currentState) { 59 SliderState.IDLE -> handleIdle(event.type, event.currentProgress) 60 SliderState.WAIT -> handleWait(event.type, event.currentProgress) 61 SliderState.DRAG_HANDLE_ACQUIRED_BY_TOUCH -> handleAcquired(event.type) 62 SliderState.DRAG_HANDLE_DRAGGING -> handleDragging(event.type, event.currentProgress) 63 SliderState.DRAG_HANDLE_REACHED_BOOKEND -> 64 handleReachedBookend(event.type, event.currentProgress) 65 SliderState.DRAG_HANDLE_RELEASED_FROM_TOUCH -> setState(SliderState.IDLE) 66 SliderState.JUMP_TRACK_LOCATION_SELECTED -> handleJumpToTrack(event.type) 67 SliderState.JUMP_BOOKEND_SELECTED -> handleJumpToBookend(event.type) 68 SliderState.ARROW_HANDLE_MOVED_ONCE -> handleArrowOnce(event.type) 69 SliderState.ARROW_HANDLE_MOVES_CONTINUOUSLY -> 70 handleArrowContinuous(event.type, event.currentProgress) 71 SliderState.ARROW_HANDLE_REACHED_BOOKEND -> handleArrowBookend() 72 } 73 latestProgress = event.currentProgress 74 } 75 handleIdlenull76 private fun handleIdle(newEventType: SliderEventType, currentProgress: Float) { 77 if (newEventType == SliderEventType.STARTED_TRACKING_TOUCH) { 78 timerJob = launchTimer() 79 // The WAIT state will wait for the timer to complete or a slider progress to occur. 80 // This will disambiguate between an imprecise touch that acquires the slider handle, 81 // and a select and jump operation in the slider track. 82 setState(SliderState.WAIT) 83 } else if (newEventType == SliderEventType.STARTED_TRACKING_PROGRAM) { 84 val state = 85 if (bookendReached(currentProgress)) SliderState.ARROW_HANDLE_REACHED_BOOKEND 86 else SliderState.ARROW_HANDLE_MOVED_ONCE 87 setState(state) 88 } 89 } 90 launchTimernull91 private fun launchTimer() = 92 scope.launch { 93 delay(config.waitTimeMillis) 94 if (isActive && currentState == SliderState.WAIT) { 95 setState(SliderState.DRAG_HANDLE_ACQUIRED_BY_TOUCH) 96 // This transitory state must also trigger the corresponding action 97 executeOnState(currentState) 98 } 99 } 100 handleWaitnull101 private fun handleWait(newEventType: SliderEventType, currentProgress: Float) { 102 // The timer may have completed and may have already modified the state 103 if (currentState != SliderState.WAIT) return 104 105 // The timer is still running but the state may be modified by the progress change 106 val deltaProgressIsJump = deltaProgressIsAboveThreshold(currentProgress) 107 if (newEventType == SliderEventType.PROGRESS_CHANGE_BY_USER) { 108 if (bookendReached(currentProgress)) { 109 setState(SliderState.JUMP_BOOKEND_SELECTED) 110 } else if (deltaProgressIsJump) { 111 setState(SliderState.JUMP_TRACK_LOCATION_SELECTED) 112 } else { 113 setState(SliderState.DRAG_HANDLE_ACQUIRED_BY_TOUCH) 114 } 115 } else if (newEventType == SliderEventType.STOPPED_TRACKING_TOUCH) { 116 setState(SliderState.IDLE) 117 } 118 119 // If the state changed, the timer does not need to complete. No further synchronization 120 // will be required onwards until WAIT is reached again. 121 if (currentState != SliderState.WAIT) { 122 timerJob?.cancel() 123 timerJob = null 124 } 125 } 126 handleAcquirednull127 private fun handleAcquired(newEventType: SliderEventType) { 128 if (newEventType == SliderEventType.STOPPED_TRACKING_TOUCH) { 129 setState(SliderState.DRAG_HANDLE_RELEASED_FROM_TOUCH) 130 } else if (newEventType == SliderEventType.PROGRESS_CHANGE_BY_USER) { 131 setState(SliderState.DRAG_HANDLE_DRAGGING) 132 } 133 } 134 handleDraggingnull135 private fun handleDragging(newEventType: SliderEventType, currentProgress: Float) { 136 if (newEventType == SliderEventType.STOPPED_TRACKING_TOUCH) { 137 setState(SliderState.DRAG_HANDLE_RELEASED_FROM_TOUCH) 138 } else if ( 139 newEventType == SliderEventType.PROGRESS_CHANGE_BY_USER && 140 bookendReached(currentProgress) 141 ) { 142 setState(SliderState.DRAG_HANDLE_REACHED_BOOKEND) 143 } 144 } 145 handleReachedBookendnull146 private fun handleReachedBookend(newEventType: SliderEventType, currentProgress: Float) { 147 if (newEventType == SliderEventType.PROGRESS_CHANGE_BY_USER) { 148 if (!bookendReached(currentProgress)) { 149 setState(SliderState.DRAG_HANDLE_DRAGGING) 150 } 151 } else if (newEventType == SliderEventType.STOPPED_TRACKING_TOUCH) { 152 setState(SliderState.DRAG_HANDLE_RELEASED_FROM_TOUCH) 153 } 154 } 155 handleJumpToTracknull156 private fun handleJumpToTrack(newEventType: SliderEventType) { 157 when (newEventType) { 158 SliderEventType.PROGRESS_CHANGE_BY_USER -> setState(SliderState.DRAG_HANDLE_DRAGGING) 159 SliderEventType.STOPPED_TRACKING_TOUCH -> 160 setState(SliderState.DRAG_HANDLE_RELEASED_FROM_TOUCH) 161 else -> {} 162 } 163 } 164 handleJumpToBookendnull165 private fun handleJumpToBookend(newEventType: SliderEventType) { 166 when (newEventType) { 167 SliderEventType.PROGRESS_CHANGE_BY_USER -> setState(SliderState.DRAG_HANDLE_DRAGGING) 168 SliderEventType.STOPPED_TRACKING_TOUCH -> 169 setState(SliderState.DRAG_HANDLE_RELEASED_FROM_TOUCH) 170 else -> {} 171 } 172 } 173 executeOnStatenull174 override fun executeOnState(currentState: SliderState) { 175 when (currentState) { 176 SliderState.DRAG_HANDLE_ACQUIRED_BY_TOUCH -> sliderListener.onHandleAcquiredByTouch() 177 SliderState.DRAG_HANDLE_RELEASED_FROM_TOUCH -> { 178 sliderListener.onHandleReleasedFromTouch() 179 // This transitory state must also reset the state machine 180 resetState() 181 } 182 SliderState.DRAG_HANDLE_DRAGGING -> sliderListener.onProgress(latestProgress) 183 SliderState.DRAG_HANDLE_REACHED_BOOKEND -> executeOnBookend() 184 SliderState.JUMP_TRACK_LOCATION_SELECTED -> 185 sliderListener.onProgressJump(latestProgress) 186 SliderState.ARROW_HANDLE_MOVED_ONCE -> sliderListener.onSelectAndArrow(latestProgress) 187 SliderState.ARROW_HANDLE_MOVES_CONTINUOUSLY -> sliderListener.onProgress(latestProgress) 188 SliderState.ARROW_HANDLE_REACHED_BOOKEND -> { 189 executeOnBookend() 190 // This transitory execution must also reset the state 191 resetState() 192 } 193 else -> {} 194 } 195 } 196 executeOnBookendnull197 private fun executeOnBookend() { 198 if (latestProgress >= config.upperBookendThreshold) sliderListener.onUpperBookend() 199 else sliderListener.onLowerBookend() 200 } 201 resetStatenull202 override fun resetState() { 203 timerJob?.cancel() 204 timerJob = null 205 super.resetState() 206 } 207 deltaProgressIsAboveThresholdnull208 private fun deltaProgressIsAboveThreshold( 209 currentProgress: Float, 210 epsilon: Float = 0.00001f, 211 ): Boolean { 212 val delta = abs(currentProgress - latestProgress) 213 return delta > config.jumpThreshold - epsilon 214 } 215 bookendReachednull216 private fun bookendReached(currentProgress: Float): Boolean { 217 return currentProgress >= config.upperBookendThreshold || 218 currentProgress <= config.lowerBookendThreshold 219 } 220 handleArrowOncenull221 private fun handleArrowOnce(newEventType: SliderEventType) { 222 val nextState = 223 when (newEventType) { 224 SliderEventType.STARTED_TRACKING_TOUCH -> { 225 // Launching the timer and going to WAIT 226 timerJob = launchTimer() 227 SliderState.WAIT 228 } 229 SliderEventType.PROGRESS_CHANGE_BY_PROGRAM -> 230 SliderState.ARROW_HANDLE_MOVES_CONTINUOUSLY 231 SliderEventType.STOPPED_TRACKING_PROGRAM -> SliderState.IDLE 232 else -> SliderState.ARROW_HANDLE_MOVED_ONCE 233 } 234 setState(nextState) 235 } 236 handleArrowContinuousnull237 private fun handleArrowContinuous(newEventType: SliderEventType, currentProgress: Float) { 238 val reachedBookend = bookendReached(currentProgress) 239 val nextState = 240 when (newEventType) { 241 SliderEventType.STOPPED_TRACKING_PROGRAM -> SliderState.IDLE 242 SliderEventType.STARTED_TRACKING_TOUCH -> { 243 // Launching the timer and going to WAIT 244 timerJob = launchTimer() 245 SliderState.WAIT 246 } 247 SliderEventType.PROGRESS_CHANGE_BY_PROGRAM -> { 248 if (reachedBookend) SliderState.ARROW_HANDLE_REACHED_BOOKEND 249 else SliderState.ARROW_HANDLE_MOVES_CONTINUOUSLY 250 } 251 else -> SliderState.ARROW_HANDLE_MOVES_CONTINUOUSLY 252 } 253 setState(nextState) 254 } 255 handleArrowBookendnull256 private fun handleArrowBookend() = setState(SliderState.IDLE) 257 258 @VisibleForTesting 259 fun setState(state: SliderState) { 260 currentState = state 261 } 262 } 263