Skip to content

Commit b5a8b6d

Browse files
authored
always complete omnibar/minibar transitions with an animation (#5848)
Task/Issue URL: https://app.asana.com/0/0/1209754552084246 ### Description Adds logic to always complete the omnibar/minibar transition. The behavior is as follows: - If user scrolls down, we always immediately start transitioning to minibar. - If user scrolls up, we only start transitioning back to omnibar after a scroll threshold since the start of last gesture is reached. This is done so that as users scroll back-and-forth around the page we don't switch between omnibar/minibar all the time - only significant scroll gestures and flings, hitting top of the page, or clicking the minibar, will reveal the omnibar back. - If gesture has any velocity, we complete the transition in the direction of the gesture. Known issues: - If we're near the bottom of the page, and **omnibar** is visible, scrolling very slowly until hitting the bottom of the page might cause a little bit of a bounce while we're force-transitioning to the minibar.
1 parent deee1fa commit b5a8b6d

File tree

3 files changed

+173
-21
lines changed

3 files changed

+173
-21
lines changed

app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2751,11 +2751,12 @@ class BrowserTabFragment :
27512751
}
27522752
}
27532753

2754-
it.setOnTouchListener { _, _ ->
2754+
it.setOnTouchListener { webView, event ->
27552755
if (omnibar.omnibarTextInput.isFocused) {
27562756
binding.focusDummy.requestFocus()
27572757
}
27582758
dismissAppLinkSnackBar()
2759+
omnibar.onScrollViewMotionEvent(scrollableView = webView, motionEvent = event)
27592760
false
27602761
}
27612762

app/src/main/java/com/duckduckgo/app/browser/omnibar/Omnibar.kt

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,30 @@ class Omnibar(
456456
}
457457
}
458458

459+
fun onScrollViewMotionEvent(scrollableView: View, motionEvent: MotionEvent) {
460+
when (omnibarPosition) {
461+
OmnibarPosition.TOP -> {
462+
when (omnibarType) {
463+
SCROLLING -> {
464+
// no-op
465+
}
466+
467+
FADE -> binding.fadeOmnibar.onScrollViewMotionEvent(scrollableView, motionEvent)
468+
}
469+
}
470+
471+
OmnibarPosition.BOTTOM -> {
472+
when (omnibarType) {
473+
SCROLLING -> {
474+
// no-op
475+
}
476+
477+
FADE -> binding.fadeOmnibarBottom.onScrollViewMotionEvent(scrollableView, motionEvent)
478+
}
479+
}
480+
}
481+
}
482+
459483
fun resetScrollPosition() {
460484
if (omnibarType == FADE) {
461485
when (omnibarPosition) {

app/src/main/java/com/duckduckgo/app/browser/omnibar/experiments/FadeOmnibarLayout.kt

Lines changed: 147 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,12 @@
1616

1717
package com.duckduckgo.app.browser.omnibar.experiments
1818

19+
import android.animation.ValueAnimator
1920
import android.content.Context
2021
import android.util.AttributeSet
22+
import android.view.MotionEvent
2123
import android.view.View
24+
import android.view.animation.DecelerateInterpolator
2225
import android.view.animation.PathInterpolator
2326
import android.widget.ImageView
2427
import androidx.core.view.isVisible
@@ -30,6 +33,8 @@ import com.duckduckgo.app.browser.omnibar.Omnibar.ViewMode
3033
import com.duckduckgo.app.browser.omnibar.OmnibarLayout
3134
import com.duckduckgo.app.browser.omnibar.OmnibarLayoutViewModel.Command
3235
import 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
3338
import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition
3439
import com.duckduckgo.common.ui.view.getColorFromAttr
3540
import com.duckduckgo.common.ui.view.text.DaxTextView
@@ -39,6 +44,7 @@ import com.duckduckgo.di.scopes.FragmentScope
3944
import com.duckduckgo.mobile.android.R as CommonR
4045
import com.google.android.material.card.MaterialCardView
4146
import dagger.android.support.AndroidSupportInjection
47+
import kotlin.math.abs
4248

4349
@InjectWith(FragmentScope::class)
4450
class 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

249376
interface FadeOmnibarItemPressedListener {

0 commit comments

Comments
 (0)