Skip to content

Commit 1dab1a2

Browse files
Mugurellmergify[bot]
authored andcommitted
For mozilla-mobile#9904 - Add ExpandableLayout as a menu wrapper used by PopupWindow
This new layout is specifically designed for one particular purpose: wrapping a bottom placed menu to allow it to: - first being displayed as collapsed until a specific menu item index - inform about touches in the empty space left by the collapsed view - automatically expand when users swipes up it will expand. Once expanded it will remain so. This was the only viable solution for allowing bottom anchored PopupWindows to be expanded and collapsed.
1 parent 8116037 commit 1dab1a2

File tree

3 files changed

+976
-0
lines changed

3 files changed

+976
-0
lines changed
Lines changed: 379 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,379 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
package mozilla.components.browser.menu.view
6+
7+
import android.animation.ValueAnimator
8+
import android.content.Context
9+
import android.graphics.Rect
10+
import android.view.MotionEvent
11+
import android.view.ViewConfiguration
12+
import android.view.ViewGroup
13+
import android.view.animation.AccelerateDecelerateInterpolator
14+
import android.widget.FrameLayout
15+
import androidx.annotation.VisibleForTesting
16+
import androidx.core.animation.doOnEnd
17+
import androidx.core.view.children
18+
import androidx.core.view.marginBottom
19+
import androidx.core.view.marginLeft
20+
import androidx.core.view.marginRight
21+
import androidx.core.view.marginTop
22+
import androidx.core.view.updateLayoutParams
23+
24+
/**
25+
* ViewGroup intended to wrap another to then allow for the following automatic behavior:
26+
* - when first laid out on the screen the wrapped view is collapsed.
27+
* - informs about touches in the empty space left by the collapsed view through [blankTouchListener].
28+
* - when users swipe up it will expand. Once expanded it remains so.
29+
*/
30+
@Suppress("TooManyFunctions")
31+
internal class ExpandableLayout private constructor(context: Context) : FrameLayout(context) {
32+
/**
33+
* The wrapped view that needs to be collapsed / expanded.
34+
*/
35+
@VisibleForTesting
36+
internal lateinit var wrappedView: ViewGroup
37+
38+
/**
39+
* Listener of touches in the empty space left by the collapsed view.
40+
*/
41+
@VisibleForTesting
42+
internal var blankTouchListener: (() -> Unit)? = null
43+
44+
/**
45+
* Index of the last menu item that should be visible when the wrapped view is collapsed.
46+
*/
47+
@VisibleForTesting
48+
internal var lastVisibleItemIndexWhenCollapsed: Int = Int.MAX_VALUE
49+
50+
/**
51+
* Height of wrapped view when collapsed.
52+
* Calculated once based on the position of the "isCollapsingMenuLimit" BrowserMenuItem.
53+
* Capped by [parentHeight]
54+
*/
55+
@VisibleForTesting
56+
internal var collapsedHeight: Int = NOT_CALCULATED_DEFAULT_HEIGHT
57+
58+
/**
59+
* Height of wrapped view when expanded.
60+
* Calculated once based on measuredHeighWithMargins().
61+
* Capped by [parentHeight]
62+
*/
63+
@VisibleForTesting
64+
internal var expandedHeight: Int = NOT_CALCULATED_DEFAULT_HEIGHT
65+
66+
/**
67+
* Available space given by the parent.
68+
*/
69+
@VisibleForTesting
70+
internal var parentHeight: Int = NOT_CALCULATED_DEFAULT_HEIGHT
71+
72+
/**
73+
* Whether to intercept touches while the view is collapsed.
74+
* If true:
75+
* - a swipe up will be intercepted and used to expand the wrapped view.
76+
* - a swipe in the empty space left by the collapsed view will be intercepted
77+
* and [blankTouchListener] will be called.
78+
* - other touches / gestures will be left to pass through to the children.
79+
*/
80+
@VisibleForTesting
81+
internal var isCollapsed = true
82+
83+
/**
84+
* Whether to intercept touches while the view is expanding.
85+
* If true:
86+
* - all touches / gestures will be intercepted.
87+
*/
88+
@VisibleForTesting
89+
internal var isExpandInProgress = false
90+
91+
/**
92+
* Distance in pixels a touch can wander before we think the user is scrolling.
93+
* (If this would be bigger than that of a child the child will react to the scroll first)
94+
*/
95+
@VisibleForTesting
96+
internal var touchSlop = ViewConfiguration.get(context).scaledTouchSlop.toFloat()
97+
98+
/**
99+
* Y axis coordinate of the [MotionEvent.ACTION_DOWN] event.
100+
* Used to calculate the distance scrolled, to know when the view should be expanded.
101+
*/
102+
@VisibleForTesting
103+
internal var initialYCoord = NOT_CALCULATED_Y_TOUCH_COORD
104+
105+
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
106+
callParentOnMeasure(widthMeasureSpec, heightMeasureSpec)
107+
108+
// Avoid new separate measure calls specifically for our usecase. Piggyback on the already requested ones.
109+
// Calculate our needed dimensions and collapse the menu when based on them.
110+
if (isCollapsed && getOrCalculateCollapsedHeight() > 0 && getOrCalculateExpandedHeight(heightMeasureSpec) > 0) {
111+
collapse()
112+
}
113+
}
114+
115+
// While this view is collapsed (not fully expanded) we want to intercept all vertical scrolls
116+
// that will be used as an indicator to expand the view,
117+
// while letting all simple touch events get handled by children's click listeners.
118+
//
119+
// Also if this view is collapsed (full height but translated) we want to treat any touch in the
120+
// invisible space as a dismiss event.
121+
@Suppress("ComplexMethod", "ReturnCount")
122+
override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
123+
if (shouldInterceptTouches()) {
124+
return when (ev?.actionMasked) {
125+
MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> {
126+
false // Allow click listeners firing for children.
127+
}
128+
MotionEvent.ACTION_DOWN -> {
129+
if (isExpandInProgress) {
130+
return true
131+
}
132+
133+
// Check if user clicked in the empty space left by this collapsed View.
134+
if (!isTouchingTheWrappedView(ev)) {
135+
blankTouchListener?.invoke()
136+
}
137+
138+
initialYCoord = ev.y
139+
140+
false // Allow click listeners firing for children.
141+
}
142+
MotionEvent.ACTION_MOVE -> {
143+
if (isExpandInProgress || !isCollapsed) {
144+
return true
145+
}
146+
147+
if (isScrollingUp(ev)) {
148+
expand()
149+
true
150+
} else {
151+
false
152+
}
153+
}
154+
else -> {
155+
// In general, we don't want to intercept touch events.
156+
// They should be handled by the child view.
157+
return callParentOnInterceptTouchEvent(ev)
158+
}
159+
}
160+
}
161+
162+
return callParentOnInterceptTouchEvent(ev)
163+
}
164+
165+
@VisibleForTesting
166+
internal fun shouldInterceptTouches() = isCollapsed || parentHeight > expandedHeight
167+
168+
@VisibleForTesting
169+
internal fun isTouchingTheWrappedView(ev: MotionEvent): Boolean {
170+
val childrenBounds = Rect()
171+
wrappedView.getHitRect(childrenBounds)
172+
return childrenBounds.contains(ev.x.toInt(), ev.y.toInt())
173+
}
174+
175+
@VisibleForTesting
176+
internal fun collapse() {
177+
wrappedView.translationY = parentHeight.toFloat() - collapsedHeight
178+
wrappedView.updateLayoutParams {
179+
height = collapsedHeight
180+
}
181+
}
182+
183+
@VisibleForTesting
184+
internal fun expand() {
185+
isCollapsed = false
186+
isExpandInProgress = true
187+
188+
val initialTranslation = wrappedView.translationY
189+
val distanceToExpandedHeight = expandedHeight - collapsedHeight
190+
getExpandViewAnimator(distanceToExpandedHeight).apply {
191+
doOnEnd {
192+
isExpandInProgress = false
193+
}
194+
195+
addUpdateListener {
196+
wrappedView.translationY = initialTranslation - it.animatedValue as Int
197+
wrappedView.updateLayoutParams {
198+
height = collapsedHeight + it.animatedValue as Int
199+
}
200+
}
201+
start()
202+
}
203+
}
204+
205+
@VisibleForTesting
206+
internal fun getExpandViewAnimator(expandDelta: Int): ValueAnimator {
207+
return ValueAnimator.ofInt(0, expandDelta).apply {
208+
this.interpolator = AccelerateDecelerateInterpolator()
209+
this.duration = DEFAULT_DURATION_EXPAND_ANIMATOR
210+
}
211+
}
212+
213+
@VisibleForTesting
214+
internal fun getOrCalculateCollapsedHeight(): Int {
215+
// Memoize the value.
216+
// Method will be called multiple times. Result will always be the same.
217+
if (collapsedHeight < 0) {
218+
collapsedHeight = calculateCollapsedHeight()
219+
}
220+
221+
return collapsedHeight
222+
}
223+
224+
@VisibleForTesting
225+
internal fun getOrCalculateExpandedHeight(heightSpec: Int): Int {
226+
if (expandedHeight < 0) {
227+
// Value from a measurement done with MeasureSpec.UNSPECIFIED.
228+
// May need to be capped by the parent height.
229+
expandedHeight = wrappedView.measuredHeight
230+
}
231+
232+
val heightSpecSize = MeasureSpec.getSize(heightSpec)
233+
// heightSpecSize can be 0 for a MeasureSpec.UNSPECIFIED.
234+
// Ignore that, wait for a heightSpec that will contain parent height.
235+
if (parentHeight < 0 && heightSpecSize > 0) {
236+
parentHeight = heightSpecSize
237+
238+
// Ensure a menu with a bigger height than the parent will be correctly laid out.
239+
expandedHeight = minOf(expandedHeight, parentHeight)
240+
241+
// Ensure the collapsedHeight we calculated is not bigger than the expanded height
242+
// now capped by parent height.
243+
// This might happen if the menu is shown in landscape and there is no space to show
244+
// the lastVisibleItemIndexWhenCollapsed.
245+
if (collapsedHeight >= expandedHeight) {
246+
// If there's no space to show the lastVisibleItemIndexWhenCollapsed even if the
247+
// wrappedView is collapsed there's no need to collapse the view.
248+
collapsedHeight = expandedHeight
249+
isExpandInProgress = false
250+
isCollapsed = false
251+
}
252+
}
253+
254+
return expandedHeight
255+
}
256+
257+
@Suppress("WrongCall")
258+
@VisibleForTesting
259+
// Used for testing protected super.onMeasure(..) calls will be executed.
260+
internal fun callParentOnMeasure(widthSpec: Int, heightSpec: Int) {
261+
super.onMeasure(widthSpec, heightSpec)
262+
}
263+
264+
@Suppress("WrongCall")
265+
@VisibleForTesting
266+
// Used for testing protected super.onInterceptTouchEvent(..) calls will be executed.
267+
internal fun callParentOnInterceptTouchEvent(ev: MotionEvent?): Boolean {
268+
return super.onInterceptTouchEvent(ev)
269+
}
270+
271+
/**
272+
* Whether based on the previous movements, when considering this [event]
273+
* it can be inferred that the user is currently scrolling up.
274+
*/
275+
@VisibleForTesting
276+
internal fun isScrollingUp(event: MotionEvent): Boolean {
277+
val yDistance = initialYCoord - event.y
278+
279+
return yDistance >= touchSlop
280+
}
281+
282+
// We need a dynamic way to get the intended collapsed height of this view before it will be laid out on the screen.
283+
// This method assumes the following layout:
284+
// ____________________________________________________
285+
// this -> | ----------------------------------- |
286+
// | ViewGroup -> | ---------------- | |
287+
// | | RecyclerView-> | View | | |
288+
// | | | View | | |
289+
// | | | View | | |
290+
// | | | SpecialView | | |
291+
// | | | View | | |
292+
// | | ---------------- | |
293+
// | ----------------------------------- |
294+
// ----------------------------------------------------
295+
// for which we want to measure the distance (height) between [this#top, half of SpecialView].
296+
// That distance will be the collapsed height of the ViewGroup used when this will be first shown on the screen.
297+
// Users will be able to afterwards expand the ViewGroup to the full height.
298+
@VisibleForTesting
299+
internal fun calculateCollapsedHeight(): Int {
300+
val listView = (wrappedView.getChildAt(0) as ViewGroup)
301+
// Simple sanity check
302+
if (lastVisibleItemIndexWhenCollapsed >= listView.childCount ||
303+
lastVisibleItemIndexWhenCollapsed <= 0) {
304+
305+
return measuredHeight
306+
}
307+
308+
var result = 0
309+
result += wrappedView.marginTop
310+
result += wrappedView.marginBottom
311+
result += wrappedView.paddingTop
312+
result += wrappedView.paddingBottom
313+
result += listView.marginTop
314+
result += listView.marginBottom
315+
result += listView.paddingTop
316+
result += listView.paddingBottom
317+
318+
listView.children.forEachIndexed { index, view ->
319+
if (index < lastVisibleItemIndexWhenCollapsed) {
320+
result += view.marginTop
321+
result += view.marginBottom
322+
result += view.paddingTop
323+
result += view.paddingBottom
324+
result += view.measuredHeight
325+
} else if (index == lastVisibleItemIndexWhenCollapsed) {
326+
result += view.marginTop
327+
result += view.paddingTop
328+
result += view.measuredHeight / 2
329+
330+
return@forEachIndexed
331+
}
332+
}
333+
334+
return result
335+
}
336+
337+
internal companion object {
338+
@VisibleForTesting
339+
const val NOT_CALCULATED_DEFAULT_HEIGHT = -1
340+
341+
@VisibleForTesting
342+
const val NOT_CALCULATED_Y_TOUCH_COORD = 0f
343+
344+
/**
345+
* Duration of the expand animation. Same value as the one from [R.android.integer.config_shortAnimTime]
346+
*/
347+
@VisibleForTesting
348+
const val DEFAULT_DURATION_EXPAND_ANIMATOR = 200L
349+
350+
/**
351+
* Wraps a content view in an [ExpandableLayout].
352+
*
353+
* @param contentView the content view to wrap.
354+
* @return a [ExpandableLayout] that wraps the content view.
355+
*/
356+
internal fun wrapContentInExpandableView(
357+
contentView: ViewGroup,
358+
lastVisibleItemIndexWhenCollapsed: Int = Int.MAX_VALUE,
359+
blankTouchListener: (() -> Unit)? = null
360+
): ExpandableLayout {
361+
362+
val expandableView = ExpandableLayout(contentView.context)
363+
val params = MarginLayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
364+
.apply {
365+
leftMargin = contentView.marginLeft
366+
topMargin = contentView.marginTop
367+
rightMargin = contentView.marginRight
368+
bottomMargin = contentView.marginBottom
369+
}
370+
expandableView.addView(contentView, params)
371+
372+
expandableView.wrappedView = contentView
373+
expandableView.blankTouchListener = blankTouchListener
374+
expandableView.lastVisibleItemIndexWhenCollapsed = lastVisibleItemIndexWhenCollapsed
375+
376+
return expandableView
377+
}
378+
}
379+
}

0 commit comments

Comments
 (0)