Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 91af11ff authored by Juan Sebastian Martinez's avatar Juan Sebastian Martinez
Browse files

Introducing Slider Tracker components for sliders in SysUI.

Components related to the state machine of a slider are introduced.
These include a SliderTracker, SliderState and the concrete
implementation of a tracker for the brightness slider in the system UI
(SeekableSliderTracker).

Test: atest SeekableSliderTrackerTest
Bug: 295932558
(cherry picked from https://googleplex-android-review.googlesource.com/q/commit:b2e4731cc439095c07ef9de06b612aa14f67efe1)

Change-Id: I04a3d3d38a9f43e7262ff419b183b1cb40177675
parent d544c646
Loading
Loading
Loading
Loading
+212 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 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 com.android.systemui.haptics.slider

import androidx.annotation.VisibleForTesting
import com.android.systemui.dagger.qualifiers.Main
import kotlin.math.abs
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch

/**
 * Slider tracker attached to a seekable slider.
 *
 * The tracker runs a state machine to execute actions on touch-based events typical of a seekable
 * slider such as [android.widget.SeekBar]. Coroutines responsible for running the state machine,
 * collecting slider events and maintaining waiting states are run on the main thread via the
 * [com.android.systemui.dagger.qualifiers.Main] coroutine dispatcher.
 *
 * @param[sliderStateListener] Listener of the slider state.
 * @param[sliderEventProducer] Producer of slider events arising from the slider.
 * @property[scope] [CoroutineScope] where the collection of slider events and the launch of timer
 *   jobs occur.
 * @property[config] Configuration parameters of the slider tracker.
 */
class SeekableSliderTracker(
    sliderStateListener: SliderStateListener,
    sliderEventProducer: SliderEventProducer,
    @Main mainDispatcher: CoroutineDispatcher,
    private val config: SeekableSliderTrackerConfig = SeekableSliderTrackerConfig(),
) : SliderTracker(CoroutineScope(mainDispatcher), sliderStateListener, sliderEventProducer) {

    // History of the latest progress collected from slider events
    private var latestProgress = 0f
    // Timer job for the wait state
    private var timerJob: Job? = null
    // Indicator that there is waiting job active
    var isWaiting = false
        private set
        get() = timerJob != null && timerJob?.isActive == true

    override suspend fun iterateState(event: SliderEvent) {
        when (currentState) {
            SliderState.IDLE -> handleIdle(event.type)
            SliderState.WAIT -> handleWait(event.type, event.currentProgress)
            SliderState.DRAG_HANDLE_ACQUIRED_BY_TOUCH -> handleAcquired(event.type)
            SliderState.DRAG_HANDLE_DRAGGING -> handleDragging(event.type, event.currentProgress)
            SliderState.DRAG_HANDLE_REACHED_BOOKEND ->
                handleReachedBookend(event.type, event.currentProgress)
            SliderState.DRAG_HANDLE_RELEASED_FROM_TOUCH -> setState(SliderState.IDLE)
            SliderState.JUMP_TRACK_LOCATION_SELECTED -> handleJumpToTrack(event.type)
            SliderState.JUMP_BOOKEND_SELECTED -> handleJumpToBookend(event.type)
        }
        latestProgress = event.currentProgress
    }

    private fun handleIdle(newEventType: SliderEventType) {
        if (newEventType == SliderEventType.STARTED_TRACKING_TOUCH) {
            timerJob = launchTimer()
            // The WAIT state will wait for the timer to complete or a slider progress to occur.
            // This will disambiguate between an imprecise touch that acquires the slider handle,
            // and a select and jump operation in the slider track.
            setState(SliderState.WAIT)
        }
    }

    private fun launchTimer() =
        scope.launch {
            delay(config.waitTimeMillis)
            if (isActive && currentState == SliderState.WAIT) {
                setState(SliderState.DRAG_HANDLE_ACQUIRED_BY_TOUCH)
                // This transitory state must also trigger the corresponding action
                executeOnState(currentState)
            }
        }

    private fun handleWait(newEventType: SliderEventType, currentProgress: Float) {
        // The timer may have completed and may have already modified the state
        if (currentState != SliderState.WAIT) return

        // The timer is still running but the state may be modified by the progress change
        val deltaProgressIsJump = deltaProgressIsAboveThreshold(currentProgress)
        if (newEventType == SliderEventType.PROGRESS_CHANGE_BY_USER) {
            if (bookendReached(currentProgress)) {
                setState(SliderState.JUMP_BOOKEND_SELECTED)
            } else if (deltaProgressIsJump) {
                setState(SliderState.JUMP_TRACK_LOCATION_SELECTED)
            } else {
                setState(SliderState.DRAG_HANDLE_ACQUIRED_BY_TOUCH)
            }
        } else if (newEventType == SliderEventType.STOPPED_TRACKING_TOUCH) {
            setState(SliderState.IDLE)
        }

        // If the state changed, the timer does not need to complete. No further synchronization
        // will be required onwards until WAIT is reached again.
        if (currentState != SliderState.WAIT) {
            timerJob?.cancel()
            timerJob = null
        }
    }

    private fun handleAcquired(newEventType: SliderEventType) {
        if (newEventType == SliderEventType.STOPPED_TRACKING_TOUCH) {
            setState(SliderState.DRAG_HANDLE_RELEASED_FROM_TOUCH)
        } else if (newEventType == SliderEventType.PROGRESS_CHANGE_BY_USER) {
            setState(SliderState.DRAG_HANDLE_DRAGGING)
        }
    }

    private fun handleDragging(newEventType: SliderEventType, currentProgress: Float) {
        if (newEventType == SliderEventType.STOPPED_TRACKING_TOUCH) {
            setState(SliderState.DRAG_HANDLE_RELEASED_FROM_TOUCH)
        } else if (
            newEventType == SliderEventType.PROGRESS_CHANGE_BY_USER &&
                bookendReached(currentProgress)
        ) {
            setState(SliderState.DRAG_HANDLE_REACHED_BOOKEND)
        }
    }

    private fun handleReachedBookend(newEventType: SliderEventType, currentProgress: Float) {
        if (newEventType == SliderEventType.PROGRESS_CHANGE_BY_USER) {
            if (!bookendReached(currentProgress)) {
                setState(SliderState.DRAG_HANDLE_DRAGGING)
            }
        } else if (newEventType == SliderEventType.STOPPED_TRACKING_TOUCH) {
            setState(SliderState.DRAG_HANDLE_RELEASED_FROM_TOUCH)
        }
    }

    private fun handleJumpToTrack(newEventType: SliderEventType) {
        when (newEventType) {
            SliderEventType.PROGRESS_CHANGE_BY_USER -> setState(SliderState.DRAG_HANDLE_DRAGGING)
            SliderEventType.STOPPED_TRACKING_TOUCH ->
                setState(SliderState.DRAG_HANDLE_RELEASED_FROM_TOUCH)
            else -> {}
        }
    }

    private fun handleJumpToBookend(newEventType: SliderEventType) {
        when (newEventType) {
            SliderEventType.PROGRESS_CHANGE_BY_USER -> setState(SliderState.DRAG_HANDLE_DRAGGING)
            SliderEventType.STOPPED_TRACKING_TOUCH ->
                setState(SliderState.DRAG_HANDLE_RELEASED_FROM_TOUCH)
            else -> {}
        }
    }

    override fun executeOnState(currentState: SliderState) {
        when (currentState) {
            SliderState.DRAG_HANDLE_ACQUIRED_BY_TOUCH -> sliderListener.onHandleAcquiredByTouch()
            SliderState.DRAG_HANDLE_RELEASED_FROM_TOUCH -> {
                sliderListener.onHandleReleasedFromTouch()
                // This transitory state must also reset the state machine
                resetState()
            }
            SliderState.DRAG_HANDLE_DRAGGING -> sliderListener.onProgress(latestProgress)
            SliderState.DRAG_HANDLE_REACHED_BOOKEND -> executeOnBookend()
            SliderState.JUMP_TRACK_LOCATION_SELECTED ->
                sliderListener.onProgressJump(latestProgress)
            SliderState.JUMP_BOOKEND_SELECTED -> executeOnBookend()
            else -> {}
        }
    }

    private fun executeOnBookend() {
        if (latestProgress >= config.upperBookendThreshold) sliderListener.onUpperBookend()
        else sliderListener.onLowerBookend()
    }

    override fun resetState() {
        timerJob?.cancel()
        timerJob = null
        super.resetState()
    }

    private fun deltaProgressIsAboveThreshold(
        currentProgress: Float,
        epsilon: Float = 0.00001f,
    ): Boolean {
        val delta = abs(currentProgress - latestProgress)
        return abs(delta - config.jumpThreshold) < epsilon
    }

    private fun bookendReached(currentProgress: Float): Boolean {
        return currentProgress >= config.upperBookendThreshold ||
            currentProgress <= config.lowerBookendThreshold
    }

    @VisibleForTesting
    fun setState(state: SliderState) {
        currentState = state
    }
}
+37 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 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 com.android.systemui.haptics.slider

import androidx.annotation.FloatRange

/**
 * Configuration parameters of a seekable slider tracker.
 *
 * @property[waitTimeMillis] Wait period to determine if a touch event acquires the slider handle.
 * @property[jumpThreshold] Threshold on the slider progress to detect if a touch event is qualified
 *   as an imprecise acquisition of the slider handle.
 * @property[lowerBookendThreshold] Threshold to determine the progress on the slider that qualifies
 *   as reaching the lower bookend.
 * @property[upperBookendThreshold] Threshold to determine the progress on the slider that qualifies
 *   as reaching the upper bookend.
 */
data class SeekableSliderTrackerConfig(
    val waitTimeMillis: Long = 100,
    @FloatRange(from = 0.0, to = 1.0) val jumpThreshold: Float = 0.02f,
    @FloatRange(from = 0.0, to = 1.0) val lowerBookendThreshold: Float = 0.05f,
    @FloatRange(from = 0.0, to = 1.0) val upperBookendThreshold: Float = 0.95f,
)
+37 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 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 com.android.systemui.haptics.slider

/** State of a slider */
enum class SliderState {
    /* The slider is idle */
    IDLE,
    /* Waiting state to disambiguate between handle acquisition and select and jump operations */
    WAIT,
    /* The slider handle was acquired by touch. */
    DRAG_HANDLE_ACQUIRED_BY_TOUCH,
    /* The slider handle was released. */
    DRAG_HANDLE_RELEASED_FROM_TOUCH,
    /* The slider handle is being dragged by touch. */
    DRAG_HANDLE_DRAGGING,
    /* The slider handle reached a bookend. */
    DRAG_HANDLE_REACHED_BOOKEND,
    /* A location in the slider track has been selected. */
    JUMP_TRACK_LOCATION_SELECTED,
    /* The slider handled moved to a bookend after it was selected. */
    JUMP_BOOKEND_SELECTED,
}
+93 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 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 com.android.systemui.haptics.slider

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch

/**
 * Tracker component for a slider.
 *
 * The tracker maintains a state machine operated by slider events coming from a
 * [SliderEventProducer]. An action is executed in each state via a [SliderListener].
 *
 * @param[scope] [CoroutineScope] to launch the collection of [SliderEvent].
 * @property[sliderListener] [SliderListener] to execute actions on a given [SliderState].
 * @property[eventProducer] Producer of [SliderEvent] to iterate over a state machine.
 */
sealed class SliderTracker(
    protected val scope: CoroutineScope,
    protected val sliderListener: SliderStateListener,
    protected val eventProducer: SliderEventProducer,
) {

    /* Reference to the current state of the internal state machine */
    var currentState: SliderState = SliderState.IDLE
        protected set

    /**
     * Job that launches and maintains the coroutine that collects events and operates the state
     * machine.
     */
    protected var job: Job? = null

    /** Indicator that the tracker is active and tracking */
    var isTracking = false
        get() = job != null && job?.isActive == true
        private set

    /** Starts the [Job] that collects slider events and runs the state machine */
    fun startTracking() {
        job =
            scope.launch {
                eventProducer.produceEvents().collect { event ->
                    iterateState(event)
                    executeOnState(currentState)
                }
            }
    }

    /** Stops the collection of slider events and the state machine */
    fun stopTracking() {
        job?.cancel("Stopped tracking slider state")
        job = null
        resetState()
    }

    /**
     * Iterate through the state machine due to a new slider event. As a result, the current state
     * is modified.
     *
     * @param[event] The slider event that is received.
     */
    protected abstract suspend fun iterateState(event: SliderEvent)

    /**
     * Execute an action based on the state of the state machine. This method should use the
     * [SliderListener] to act on the current state.
     *
     * @param[currentState] A [SliderState] in the state machine
     */
    protected abstract fun executeOnState(currentState: SliderState)

    /** Reset the state machine by setting the current state to [SliderState.IDLE] */
    protected open fun resetState() {
        currentState = SliderState.IDLE
    }
}
+32 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 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 com.android.systemui.haptics.slider

import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow

/** Fake implementation of a slider event producer */
class FakeSliderEventProducer : SliderEventProducer {

    private val _currentEvent = MutableStateFlow(SliderEvent(SliderEventType.NOTHING, 0f))

    fun sendEvent(event: SliderEvent) {
        _currentEvent.value = event
    }
    override fun produceEvents(): Flow<SliderEvent> = _currentEvent.asStateFlow()
}
Loading