diff options
| author | 2024-10-28 15:03:58 +0000 | |
|---|---|---|
| committer | 2024-12-19 12:44:50 +0000 | |
| commit | eebd136f1103bce576252a0129536a8e957dc074 (patch) | |
| tree | 6c3424dfa550d06a042aa23c3c773cd37c97bff3 | |
| parent | 559895c29f230f97954b74cf891a2f3daf106af3 (diff) | |
Add overscroll animation to the Volume Dialog sliders
Flag: com.android.systemui.volume_redesign
Test: manual on foldable
Bug: 369995895
Change-Id: I31b79f00a18284fee4b7c691f0f484147153c545
10 files changed, 274 insertions, 19 deletions
diff --git a/packages/SystemUI/res/layout/volume_dialog.xml b/packages/SystemUI/res/layout/volume_dialog.xml index a3bad8f012ac..054c4f2f052b 100644 --- a/packages/SystemUI/res/layout/volume_dialog.xml +++ b/packages/SystemUI/res/layout/volume_dialog.xml @@ -71,6 +71,9 @@ android:layout_height="0dp" android:layout_marginTop="@dimen/volume_dialog_floating_sliders_vertical_padding_negative" android:layout_marginBottom="@dimen/volume_dialog_floating_sliders_vertical_padding_negative" + android:clipChildren="false" + android:clipToOutline="false" + android:clipToPadding="false" android:divider="@drawable/volume_dialog_floating_sliders_spacer" android:gravity="bottom" android:orientation="horizontal" diff --git a/packages/SystemUI/res/values/dimens.xml b/packages/SystemUI/res/values/dimens.xml index 35cfd082c537..05c4d1b662db 100644 --- a/packages/SystemUI/res/values/dimens.xml +++ b/packages/SystemUI/res/values/dimens.xml @@ -2106,6 +2106,8 @@ <fraction name="volume_dialog_half_opened_bias">0.2</fraction> + <dimen name="volume_dialog_slider_max_deviation">56dp</dimen> + <dimen name="volume_dialog_background_square_corner_radius">12dp</dimen> <dimen name="volume_dialog_ringer_drawer_button_size">@dimen/volume_dialog_button_size</dimen> diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/sliders/dagger/VolumeDialogSliderComponent.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/sliders/dagger/VolumeDialogSliderComponent.kt index c0c525bcb37d..88af210b6a36 100644 --- a/packages/SystemUI/src/com/android/systemui/volume/dialog/sliders/dagger/VolumeDialogSliderComponent.kt +++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/sliders/dagger/VolumeDialogSliderComponent.kt @@ -17,6 +17,7 @@ package com.android.systemui.volume.dialog.sliders.dagger import com.android.systemui.volume.dialog.sliders.domain.model.VolumeDialogSliderType +import com.android.systemui.volume.dialog.sliders.ui.VolumeDialogOverscrollViewBinder import com.android.systemui.volume.dialog.sliders.ui.VolumeDialogSliderHapticsViewBinder import com.android.systemui.volume.dialog.sliders.ui.VolumeDialogSliderTouchesViewBinder import com.android.systemui.volume.dialog.sliders.ui.VolumeDialogSliderViewBinder @@ -37,6 +38,8 @@ interface VolumeDialogSliderComponent { fun sliderHapticsViewBinder(): VolumeDialogSliderHapticsViewBinder + fun overscrollViewBinder(): VolumeDialogOverscrollViewBinder + @Subcomponent.Factory interface Factory { diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/sliders/data/repository/VolumeDialogSliderTouchEventsRepository.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/sliders/data/repository/VolumeDialogSliderTouchEventsRepository.kt index adc2383c3a46..82885d65c513 100644 --- a/packages/SystemUI/src/com/android/systemui/volume/dialog/sliders/data/repository/VolumeDialogSliderTouchEventsRepository.kt +++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/sliders/data/repository/VolumeDialogSliderTouchEventsRepository.kt @@ -16,7 +16,6 @@ package com.android.systemui.volume.dialog.sliders.data.repository -import android.annotation.SuppressLint import android.view.MotionEvent import com.android.systemui.volume.dialog.sliders.dagger.VolumeDialogSliderScope import javax.inject.Inject @@ -27,7 +26,6 @@ import kotlinx.coroutines.flow.filterNotNull @VolumeDialogSliderScope class VolumeDialogSliderTouchEventsRepository @Inject constructor() { - @SuppressLint("SharedFlowCreation") private val mutableSliderTouchEvents: MutableStateFlow<MotionEvent?> = MutableStateFlow(null) val sliderTouchEvent: Flow<MotionEvent> = mutableSliderTouchEvents.filterNotNull() diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/sliders/domain/interactor/VolumeDialogSliderInteractor.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/sliders/domain/interactor/VolumeDialogSliderInteractor.kt index 2967fe8ca906..04dc80c45a18 100644 --- a/packages/SystemUI/src/com/android/systemui/volume/dialog/sliders/domain/interactor/VolumeDialogSliderInteractor.kt +++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/sliders/domain/interactor/VolumeDialogSliderInteractor.kt @@ -17,14 +17,18 @@ package com.android.systemui.volume.dialog.sliders.domain.interactor import com.android.systemui.plugins.VolumeDialogController +import com.android.systemui.volume.dialog.dagger.scope.VolumeDialog import com.android.systemui.volume.dialog.domain.interactor.VolumeDialogStateInteractor import com.android.systemui.volume.dialog.shared.model.VolumeDialogStreamModel import com.android.systemui.volume.dialog.sliders.dagger.VolumeDialogSliderScope import com.android.systemui.volume.dialog.sliders.domain.model.VolumeDialogSliderType import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.stateIn /** Operates a state of particular slider of the Volume Dialog. */ @VolumeDialogSliderScope @@ -32,6 +36,7 @@ class VolumeDialogSliderInteractor @Inject constructor( private val sliderType: VolumeDialogSliderType, + @VolumeDialog private val coroutineScope: CoroutineScope, volumeDialogStateInteractor: VolumeDialogStateInteractor, private val volumeDialogController: VolumeDialogController, ) { @@ -47,7 +52,8 @@ constructor( } } } - .distinctUntilChanged() + .stateIn(coroutineScope, SharingStarted.Eagerly, null) + .filterNotNull() fun setStreamVolume(userLevel: Int) { with(volumeDialogController) { diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/sliders/ui/VolumeDialogOverscrollViewBinder.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/sliders/ui/VolumeDialogOverscrollViewBinder.kt new file mode 100644 index 000000000000..8109b50aa34a --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/sliders/ui/VolumeDialogOverscrollViewBinder.kt @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2024 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.volume.dialog.sliders.ui + +import android.view.View +import androidx.dynamicanimation.animation.FloatValueHolder +import androidx.dynamicanimation.animation.SpringAnimation +import androidx.dynamicanimation.animation.SpringForce +import com.android.systemui.res.R +import com.android.systemui.volume.dialog.sliders.dagger.VolumeDialogSliderScope +import com.android.systemui.volume.dialog.sliders.ui.viewmodel.VolumeDialogOverscrollViewModel +import com.android.systemui.volume.dialog.sliders.ui.viewmodel.VolumeDialogOverscrollViewModel.OverscrollEventModel +import com.google.android.material.slider.Slider +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +@VolumeDialogSliderScope +class VolumeDialogOverscrollViewBinder +@Inject +constructor(private val viewModel: VolumeDialogOverscrollViewModel) { + + /** + * [viewsToAnimate] is an array of [View] to be affected by the overscroll animation. [view] is + * NOT animated by default. + */ + fun CoroutineScope.bind(view: View, viewsToAnimate: Array<View>) { + val animationValueHolder = FloatValueHolder(0f) + val animation: SpringAnimation = + SpringAnimation(animationValueHolder) + .setSpring( + SpringForce(0f).apply { + stiffness = 800f + dampingRatio = 0.6f + } + ) + .addUpdateListener { _, value, _ -> viewsToAnimate.setTranslationY(value) } + + view.requireViewById<Slider>(R.id.volume_dialog_slider).addOnChangeListener { s, value, _ -> + viewModel.setSlider(value = value, min = s.valueFrom, max = s.valueTo) + } + + viewModel.overscrollEvent + .onEach { event -> + when (event) { + is OverscrollEventModel.Animate -> { + animation.animateToFinalPosition(event.targetOffsetPx) + } + is OverscrollEventModel.Move -> { + animation.cancel() + viewsToAnimate.setTranslationY(event.touchOffsetPx) + animationValueHolder.value = event.touchOffsetPx + } + } + } + .launchIn(this) + } +} + +private fun Array<View>.setTranslationY(translation: Float) { + for (viewToAnimate in this) { + viewToAnimate.translationY = translation + } +} diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/sliders/ui/VolumeDialogSliderViewBinder.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/sliders/ui/VolumeDialogSliderViewBinder.kt index f30524638150..ba658fe30787 100644 --- a/packages/SystemUI/src/com/android/systemui/volume/dialog/sliders/ui/VolumeDialogSliderViewBinder.kt +++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/sliders/ui/VolumeDialogSliderViewBinder.kt @@ -55,22 +55,21 @@ constructor( viewModel.setStreamVolume(value.roundToInt(), fromUser) } - viewModel.state.onEach { it.bindToSlider(sliderView) }.launchIn(this) + viewModel.state.onEach { sliderView.setModel(it) }.launchIn(this) } @SuppressLint("UseCompatLoadingForDrawables") - private suspend fun VolumeDialogSliderStateModel.bindToSlider(slider: Slider) { - with(slider) { - valueFrom = minValue - valueTo = maxValue - // coerce the current value to the new value range before animating it - value = value.coerceIn(valueFrom, valueTo) - setValueAnimated( - value, - jankListenerFactory.update(this, PROGRESS_CHANGE_ANIMATION_DURATION_MS), - ) - trackIconActiveEnd = context.getDrawable(iconRes) - } + private suspend fun Slider.setModel(model: VolumeDialogSliderStateModel) { + valueFrom = model.minValue + valueTo = model.maxValue + // coerce the current value to the new value range before animating it. This prevents + // animating from the value that is outside of current [valueFrom, valueTo]. + value = value.coerceIn(valueFrom, valueTo) + setValueAnimated( + model.value, + jankListenerFactory.update(this, PROGRESS_CHANGE_ANIMATION_DURATION_MS), + ) + trackIconActiveEnd = context.getDrawable(model.iconRes) } } diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/sliders/ui/VolumeDialogSlidersViewBinder.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/sliders/ui/VolumeDialogSlidersViewBinder.kt index c9b525930ed3..f066b56e7de0 100644 --- a/packages/SystemUI/src/com/android/systemui/volume/dialog/sliders/ui/VolumeDialogSlidersViewBinder.kt +++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/sliders/ui/VolumeDialogSlidersViewBinder.kt @@ -40,9 +40,17 @@ constructor(private val viewModel: VolumeDialogSlidersViewModel) { view.requireViewById(R.id.volume_dialog_floating_sliders_container) val mainSliderContainer: View = view.requireViewById(R.id.volume_dialog_main_slider_container) + val background: View = view.requireViewById(R.id.volume_dialog_background) + val settingsButton: View = view.requireViewById(R.id.volume_dialog_settings) + val ringerDrawer: View = view.requireViewById(R.id.volume_ringer_drawer) + viewModel.sliders .onEach { uiModel -> - bindSlider(uiModel.sliderComponent, mainSliderContainer) + bindSlider( + uiModel.sliderComponent, + mainSliderContainer, + arrayOf(mainSliderContainer, background, settingsButton, ringerDrawer), + ) val floatingSliderViewBinders = uiModel.floatingSliderComponent floatingSlidersContainer.ensureChildCount( @@ -50,7 +58,8 @@ constructor(private val viewModel: VolumeDialogSlidersViewModel) { count = floatingSliderViewBinders.size, ) floatingSliderViewBinders.fastForEachIndexed { index, sliderComponent -> - bindSlider(sliderComponent, floatingSlidersContainer.getChildAt(index)) + val sliderContainer = floatingSlidersContainer.getChildAt(index) + bindSlider(sliderComponent, sliderContainer, arrayOf(sliderContainer)) } } .launchIn(this) @@ -59,10 +68,12 @@ constructor(private val viewModel: VolumeDialogSlidersViewModel) { private fun CoroutineScope.bindSlider( component: VolumeDialogSliderComponent, sliderContainer: View, + viewsToAnimate: Array<View>, ) { with(component.sliderViewBinder()) { bind(sliderContainer) } with(component.sliderTouchesViewBinder()) { bind(sliderContainer) } with(component.sliderHapticsViewBinder()) { bind(sliderContainer) } + with(component.overscrollViewBinder()) { bind(sliderContainer, viewsToAnimate) } } } diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/sliders/ui/viewmodel/VolumeDialogOverscrollViewModel.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/sliders/ui/viewmodel/VolumeDialogOverscrollViewModel.kt new file mode 100644 index 000000000000..0d41860d9f57 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/sliders/ui/viewmodel/VolumeDialogOverscrollViewModel.kt @@ -0,0 +1,152 @@ +/* + * Copyright (C) 2024 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.volume.dialog.sliders.ui.viewmodel + +import android.content.Context +import android.view.MotionEvent +import android.view.animation.PathInterpolator +import com.android.systemui.res.R +import com.android.systemui.volume.dialog.sliders.dagger.VolumeDialogSliderScope +import com.android.systemui.volume.dialog.sliders.domain.interactor.VolumeDialogSliderInputEventsInteractor +import com.android.systemui.volume.dialog.sliders.shared.model.SliderInputEvent +import javax.inject.Inject +import kotlin.math.abs +import kotlin.math.sign +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.transform + +@VolumeDialogSliderScope +@OptIn(ExperimentalCoroutinesApi::class) +class VolumeDialogOverscrollViewModel +@Inject +constructor( + context: Context, + private val inputEventsInteractor: VolumeDialogSliderInputEventsInteractor, +) { + + /** + * This is the ratio between the pointer distance and the dialog offset. The pointer has to + * travel this distance for a single point of an offset. + * + * When greater than 1 this makes the dialog to follow the touch behind. + */ + private val offsetToTranslationRatio: Float = 3f + private val maxDeviation: Float = + context.resources + .getDimensionPixelSize(R.dimen.volume_dialog_slider_max_deviation) + .toFloat() + private val offsetInterpolator = PathInterpolator(0.15f, 0.00f, 0.20f, 1.00f) + + private val sliderValue = MutableStateFlow<Slider?>(null) + + val overscrollEvent: Flow<OverscrollEventModel> = + sliderValue + .filterNotNull() + .map { slider -> + when (slider.value) { + slider.min -> 1f + slider.max -> -1f + else -> 0f + } + } + .distinctUntilChanged() + .flatMapLatest { direction -> + if (direction == 0f) { + flowOf(OverscrollEventModel.Animate(0f)) + } else { + overscrollEvents(direction) + } + } + + fun setSlider(value: Float, min: Float, max: Float) { + sliderValue.value = Slider(value = value, min = min, max = max) + } + + /** + * Returns a flow that for each another [MotionEvent] it receives maps into a path from the + * first event. + * + * Emits [OverscrollEventModel.Move] that follows the [SliderInputEvent.Touch] from the pointer + * down position. Emits [OverscrollEventModel.Animate] when the gesture is terminated to create + * a spring-back effect. + */ + private fun overscrollEvents(direction: Float): Flow<OverscrollEventModel> { + var startPosition: Float? = null + return inputEventsInteractor.event + .mapNotNull { (it as? SliderInputEvent.Touch)?.event } + .transform { touchEvent -> + // Skip events from inside the slider bounds for the case when the user adjusts + // slider + // towards max when the slider is already on max value. + if (touchEvent.isFinalEvent()) { + startPosition = null + emit(OverscrollEventModel.Animate(0f)) + return@transform + } + val currentStartPosition = startPosition + val newPosition: Float = touchEvent.rawY + if (currentStartPosition == null) { + startPosition = newPosition + } else { + val offset = (newPosition - currentStartPosition) / offsetToTranslationRatio + val interpolatedOffset = + if (areOfTheSameSign(direction, offset)) { + sign(offset) * + (maxDeviation * + offsetInterpolator.getInterpolation( + (abs(offset)) / maxDeviation + )) + } else { + 0f + } + emit(OverscrollEventModel.Move(interpolatedOffset)) + } + } + } + + /** @return true when the [MotionEvent] indicates the end of the gesture. */ + private fun MotionEvent.isFinalEvent(): Boolean { + return actionMasked == MotionEvent.ACTION_UP || actionMasked == MotionEvent.ACTION_CANCEL + } + + /** Models overscroll event */ + sealed interface OverscrollEventModel { + + /** Notifies the consumed to move by the [touchOffsetPx]. */ + data class Move(val touchOffsetPx: Float) : OverscrollEventModel + + /** Notifies the consume to animate to the [targetOffsetPx]. */ + data class Animate(val targetOffsetPx: Float) : OverscrollEventModel + } + + private data class Slider(val value: Float, val min: Float, val max: Float) +} + +private fun areOfTheSameSign(lhs: Float, rhs: Float): Boolean = + when { + lhs < 0 -> rhs < 0 + lhs > 0 -> rhs > 0 + else -> rhs == 0f + } diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/volume/dialog/sliders/domain/interactor/VolumeDialogSliderInteractorKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/volume/dialog/sliders/domain/interactor/VolumeDialogSliderInteractorKosmos.kt index 423100a1addf..44917dd4ba48 100644 --- a/packages/SystemUI/tests/utils/src/com/android/systemui/volume/dialog/sliders/domain/interactor/VolumeDialogSliderInteractorKosmos.kt +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/volume/dialog/sliders/domain/interactor/VolumeDialogSliderInteractorKosmos.kt @@ -17,6 +17,7 @@ package com.android.systemui.volume.dialog.sliders.domain.interactor import com.android.systemui.kosmos.Kosmos +import com.android.systemui.kosmos.applicationCoroutineScope import com.android.systemui.plugins.volumeDialogController import com.android.systemui.volume.dialog.domain.interactor.volumeDialogStateInteractor import com.android.systemui.volume.dialog.sliders.domain.model.volumeDialogSliderType @@ -25,6 +26,7 @@ val Kosmos.volumeDialogSliderInteractor: VolumeDialogSliderInteractor by Kosmos.Fixture { VolumeDialogSliderInteractor( volumeDialogSliderType, + applicationCoroutineScope, volumeDialogStateInteractor, volumeDialogController, ) |