1616
1717package com.duckduckgo.app.browser.omnibar.experiments
1818
19+ import android.animation.ValueAnimator
1920import android.content.Context
2021import android.util.AttributeSet
22+ import android.view.MotionEvent
2123import android.view.View
24+ import android.view.animation.DecelerateInterpolator
2225import android.view.animation.PathInterpolator
2326import android.widget.ImageView
2427import androidx.core.view.isVisible
@@ -30,6 +33,8 @@ import com.duckduckgo.app.browser.omnibar.Omnibar.ViewMode
3033import com.duckduckgo.app.browser.omnibar.OmnibarLayout
3134import com.duckduckgo.app.browser.omnibar.OmnibarLayoutViewModel.Command
3235import com.duckduckgo.app.browser.omnibar.OmnibarLayoutViewModel.ViewState
36+ import com.duckduckgo.app.browser.omnibar.experiments.FadeOmnibarLayout.TransitionType.CompleteCurrentTransition
37+ import com.duckduckgo.app.browser.omnibar.experiments.FadeOmnibarLayout.TransitionType.TransitionToTarget
3338import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition
3439import com.duckduckgo.common.ui.view.getColorFromAttr
3540import com.duckduckgo.common.ui.view.text.DaxTextView
@@ -39,6 +44,7 @@ import com.duckduckgo.di.scopes.FragmentScope
3944import com.duckduckgo.mobile.android.R as CommonR
4045import com.google.android.material.card.MaterialCardView
4146import dagger.android.support.AndroidSupportInjection
47+ import kotlin.math.abs
4248
4349@InjectWith(FragmentScope ::class )
4450class FadeOmnibarLayout @JvmOverloads constructor(
@@ -68,6 +74,9 @@ class FadeOmnibarLayout @JvmOverloads constructor(
6874 private var transitionProgress = 0f
6975 private var maximumTextInputWidth: Int = 0
7076
77+ private var isGestureInProgress: Boolean = false
78+ private var scrollYOnGestureStart = 0
79+
7180 // ease-in-out interpolation
7281 private val interpolator = PathInterpolator (0.42f , 0f , 0.58f , 1f )
7382
@@ -84,7 +93,7 @@ class FadeOmnibarLayout @JvmOverloads constructor(
8493 inflate(context, R .layout.view_fade_omnibar, this )
8594
8695 minibarClickSurface.setOnClickListener {
87- revealToolbar()
96+ revealToolbar(animated = true )
8897 }
8998
9099 AndroidSupportInjection .inject(this )
@@ -122,7 +131,34 @@ class FadeOmnibarLayout @JvmOverloads constructor(
122131
123132 fun resetTransitionDelayed () {
124133 postDelayed(delayInMillis = 100 ) {
125- revealToolbar()
134+ revealToolbar(animated = false )
135+ }
136+ }
137+
138+ fun onScrollViewMotionEvent (
139+ scrollableView : View ,
140+ motionEvent : MotionEvent ,
141+ ) {
142+ when (motionEvent.actionMasked) {
143+ MotionEvent .ACTION_DOWN -> {
144+ animator?.cancel()
145+ isGestureInProgress = true
146+ scrollYOnGestureStart = scrollableView.scrollY
147+ }
148+
149+ MotionEvent .ACTION_UP , MotionEvent .ACTION_CANCEL -> {
150+ isGestureInProgress = false
151+
152+ // Most of user gestures will end with a little bit of fling, so users will not be gesturing anymore once the views stop scrolling,
153+ // and logic from #onScrollChanged takes over.
154+ // However, in cases where user releases the gesture without any acceleration, we need to reconsider all the cases here as well.
155+ applyTopOrBottomPageConditionOrElse(scrollableView, isGestureInProgress = false ) {
156+ // if user released the gesture in the middle of a transition, without any direction, complete it based on progress
157+ if (isTransitioning()) {
158+ animateTransition(transitionType = CompleteCurrentTransition )
159+ }
160+ }
161+ }
126162 }
127163 }
128164
@@ -131,54 +167,127 @@ class FadeOmnibarLayout @JvmOverloads constructor(
131167 scrollY : Int ,
132168 oldScrollY : Int ,
133169 ) {
134- if (! scrollableView.canScrollVertically(- 1 )) { // top of the page condition
135- revealToolbar()
136- } else if (! scrollableView.canScrollVertically(1 )) { // bottom of the page condition
170+ animator?.cancel()
171+ applyTopOrBottomPageConditionOrElse(scrollableView, isGestureInProgress) {
172+ val scrollDelta = scrollY - oldScrollY
173+
174+ // always allow to continue the transition if it's already started
175+ val isTransitioning = isTransitioning()
176+
177+ // always allow the transition to minibar if scrolling down
178+ val isScrollingDown = scrollDelta > 0
179+
180+ // only allow the transition back to toolbar if the scroll since start of the gesture is past a threshold
181+ val scrollDeltaSinceStartOfGesture = scrollYOnGestureStart - scrollY
182+ val isScrollingUpPastThreshold = scrollDeltaSinceStartOfGesture > SCROLL_UP_THRESHOLD_TO_START_TRANSITION_DP .toPx(context)
183+
184+ if (isTransitioning || isScrollingDown || isScrollingUpPastThreshold) {
185+ val changeRatio = scrollDelta / FULL_TRANSITION_SCROLL_DP .toPx(context)
186+ val progress = (transitionProgress + changeRatio).coerceIn(0f , 1f )
187+ evaluateTransition(progress)
188+
189+ // schedule an animation to finish the transition in the current direction, but only if user is not gesturing anymore
190+ if (! isGestureInProgress) {
191+ val target = if (scrollDelta > 0 ) {
192+ 1f
193+ } else {
194+ 0f
195+ }
196+ animateTransition(transitionType = TransitionToTarget (target = target))
197+ }
198+ }
199+ }
200+ }
201+
202+ private fun animateTransition (transitionType : TransitionType ) {
203+ animator?.cancel()
204+ val currentProgress = transitionProgress
205+
206+ val targetProgress = when (transitionType) {
207+ is CompleteCurrentTransition -> {
208+ if (currentProgress > 0.5f ) 1f else 0f
209+ }
210+
211+ is TransitionToTarget -> {
212+ transitionType.target
213+ }
214+ }
215+
216+ if (currentProgress != targetProgress) {
217+ animator = ValueAnimator .ofFloat(currentProgress, targetProgress).apply {
218+ val remainingTransitionPercentage = abs(targetProgress - currentProgress)
219+ duration = (MAX_TRANSITION_DURATION_MS * remainingTransitionPercentage).toLong()
220+ interpolator = DecelerateInterpolator ()
221+ addUpdateListener { evaluateTransition(it.animatedValue as Float ) }
222+ start()
223+ }
224+ }
225+ }
226+
227+ private fun isTransitioning (): Boolean {
228+ return transitionProgress > 0f && transitionProgress < 1f
229+ }
230+
231+ /* *
232+ * Checks whether the view can still be scrolled in either direction.
233+ * If not, reveals the toolbar (top of the page) or minibar (bottom of the page).
234+ * If yes, runs the logic provided in [ifNotTopOrBottomFun].
235+ */
236+ private fun applyTopOrBottomPageConditionOrElse (
237+ scrollableView : View ,
238+ isGestureInProgress : Boolean ,
239+ ifNotTopOrBottomFun : () -> Unit ,
240+ ) {
241+ if (! isGestureInProgress && ! scrollableView.canScrollVertically(- 1 )) { // top of the page condition
242+ revealToolbar(animated = true )
243+ } else if (! isGestureInProgress && ! scrollableView.canScrollVertically(1 )) { // bottom of the page condition
137244 revealMinibar()
138245 } else {
139- val scrollDelta = scrollY - oldScrollY
140- // We define that scrolling by 76dp should fully expand or fully collapse the toolbar
141- val changeRatio = scrollDelta / 76 .toPx(context).toFloat()
142- val progress = (transitionProgress + changeRatio).coerceIn(0f , 1f )
143- evaluateTransition(progress)
246+ ifNotTopOrBottomFun()
144247 }
145248 }
146249
147- private fun revealToolbar () {
148- evaluateTransition(progress = 0f )
250+ private fun revealToolbar (animated : Boolean ) {
251+ if (animated) {
252+ animateTransition(transitionType = TransitionToTarget (target = 0f ))
253+ } else {
254+ animator?.cancel()
255+ evaluateTransition(0f )
256+ }
149257 }
150258
151259 private fun revealMinibar () {
152- evaluateTransition(progress = 1f )
260+ animateTransition(transitionType = TransitionToTarget (target = 1f ) )
153261 }
154262
155263 private fun evaluateTransition (progress : Float ) {
156- if (maximumTextInputWidth == 0 ) {
264+ if (transitionProgress == 0f ) {
157265 // the maximum input text width is only available after the layout is evaluated because it occupies all available space on screen
266+ // on top of that, icons in the toolbar can show/hide dynamically depending on the state and enabled features
267+ // to work around this problem, we re-measure the maximum width whenever the toolbar is fully visible
158268 maximumTextInputWidth = omnibarTextInput.width
159269 }
160270
161- val wasTransitioning = transitionProgress > 0
162- val isTransitioning = progress > 0
271+ val wasToolbar = transitionProgress <= 0
272+ val isToolbar = progress <= 0
163273 transitionProgress = progress
164274 val transitionInterpolation = interpolator.getInterpolation(transitionProgress)
165- val justStartedTransitioning = ! wasTransitioning && isTransitioning
275+ val justStartedTransitioning = wasToolbar && ! isToolbar
166276
167277 if (justStartedTransitioning) {
168278 // cancel animations at minibar starts showing
169279 viewModel.onStartedTransforming()
170280 // when the minibar is expanded, capture clicks
171281 setMinibarClickCaptureState(enabled = true )
172- } else if (! isTransitioning ) {
282+ } else if (isToolbar ) {
173283 // when the toolbar is expanded, forward clicks to the underlying views
174284 setMinibarClickCaptureState(enabled = false )
175285 }
176286
177287 // hide toolbar views
178288 val toolbarViewsAlpha = 1f - transitionInterpolation
179289 omnibarTextInput.alpha = toolbarViewsAlpha
180- aiChatDivider.alpha = toolbarViewsAlpha
181- aiChat.alpha = toolbarViewsAlpha
290+ endIconsContainer.alpha = toolbarViewsAlpha
182291
183292 // show minibar views
184293 minibarText.alpha = transitionInterpolation
@@ -225,6 +334,8 @@ class FadeOmnibarLayout @JvmOverloads constructor(
225334 }
226335 }
227336
337+ var animator: ValueAnimator ? = null
338+
228339 private fun setMinibarClickCaptureState (enabled : Boolean ) {
229340 minibarClickSurface.isClickable = enabled
230341 minibarClickSurface.isLongClickable = enabled
@@ -244,6 +355,22 @@ class FadeOmnibarLayout @JvmOverloads constructor(
244355 fadeOmnibarItemPressedListener?.onDuckChatButtonPressed()
245356 }
246357 }
358+
359+ private sealed class TransitionType {
360+ data object CompleteCurrentTransition : TransitionType ()
361+ data class TransitionToTarget (val target : Float ) : TransitionType()
362+ }
363+
364+ private companion object {
365+ private const val MAX_TRANSITION_DURATION_MS = 300L
366+
367+ // We define that scrolling by 76dp should fully expand or fully collapse the toolbar
368+ private const val FULL_TRANSITION_SCROLL_DP = 76f
369+
370+ // We transition to minibar as soon as users starts scrolling
371+ // but we require a least 4 times as much of up scroll to start the transition back to the toolbar
372+ private const val SCROLL_UP_THRESHOLD_TO_START_TRANSITION_DP = FULL_TRANSITION_SCROLL_DP * 4
373+ }
247374}
248375
249376interface FadeOmnibarItemPressedListener {
0 commit comments