summaryrefslogtreecommitdiffhomepage
path: root/android
diff options
context:
space:
mode:
authorJanito Vaqueiro Ferreira Filho <janito@mullvad.net>2020-05-29 14:18:09 +0000
committerJanito Vaqueiro Ferreira Filho <janito@mullvad.net>2020-06-02 16:32:17 +0000
commita7c969c0119babc68e0699fdd6346d79cc31f998 (patch)
tree377fca6004120404fa696dc5f65367a97e398c03 /android
parenta9705fc7c54b3277a71aec995e3ef2355d7b15a9 (diff)
downloadmullvadvpn-a7c969c0119babc68e0699fdd6346d79cc31f998.tar.xz
mullvadvpn-a7c969c0119babc68e0699fdd6346d79cc31f998.zip
Create `CollapsibleTitleController` class
Diffstat (limited to 'android')
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/ui/CollapsibleTitleController.kt196
1 files changed, 196 insertions, 0 deletions
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/CollapsibleTitleController.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/CollapsibleTitleController.kt
new file mode 100644
index 0000000000..5cc6e8ed50
--- /dev/null
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/CollapsibleTitleController.kt
@@ -0,0 +1,196 @@
+package net.mullvad.mullvadvpn.ui
+
+import android.view.View
+import android.view.View.OnLayoutChangeListener
+import android.view.ViewGroup.MarginLayoutParams
+import kotlin.properties.Delegates.observable
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.ui.widget.ListenableScrollView
+import net.mullvad.mullvadvpn.util.LinearInterpolation
+
+// In order to use this view controller, the parent view must contain four views with specific IDs:
+//
+// 1. A `ListenableScrollView` with the ID `scroll_area`, which is used to animate the title based
+// on the scroll offset.
+// 2. A view inside the `scroll_area` with the ID `expanded_title`. This view is made invisible so
+// that it's not drawn, but it is used to measure the layout and the animation positions.
+// 3. A view outside the `scroll_area` with the ID `collapsed_title`. This view is also made
+// invisible just like the `expanded_view`.
+// 4. A view with the ID `title`. This is the view that's actually drawn, and it's position and size
+// are interpolated from the expanded title to the collapsed title. This view should be placed
+// somewhere where it is drawn over all other views.
+//
+// The animation interpolation is calculated based on the Y scroll offset of the scroll area. Once
+// the offset reaches a value that completely hides the expanded title inside the scroll view, the
+// animation finishes with the title being in the collapsed state.
+class CollapsibleTitleController(val parentView: View) {
+ private inner class LayoutListener(val listener: () -> Unit) : OnLayoutChangeListener {
+ override fun onLayoutChange(
+ view: View,
+ left: Int,
+ top: Int,
+ right: Int,
+ bottom: Int,
+ oldLeft: Int,
+ oldTop: Int,
+ oldRight: Int,
+ oldBottom: Int
+ ) {
+ listener.invoke()
+ update()
+ }
+ }
+
+ private val scaleInterpolation = LinearInterpolation()
+ private val scrollInterpolation = LinearInterpolation()
+ private val xOffsetInterpolation = LinearInterpolation()
+ private val yOffsetInterpolation = LinearInterpolation()
+
+ private val collapsedTitleLayoutListener: LayoutListener = LayoutListener() {
+ val (x, y) = calculateViewCoordinates(collapsedTitle)
+
+ collapsedTitleHeight = collapsedTitle.height.toFloat()
+
+ scaleInterpolation.end = collapsedTitleHeight / maxOf(1.0f, titleHeight)
+ xOffsetInterpolation.end = x
+ yOffsetInterpolation.end = y
+ }
+
+ private val collapsedTitle = parentView.findViewById<View>(R.id.collapsed_title).apply {
+ addOnLayoutChangeListener(collapsedTitleLayoutListener)
+ visibility = View.INVISIBLE
+ }
+
+ private val expandedTitleLayoutListener: LayoutListener = LayoutListener() {
+ val (x, y) = calculateViewCoordinates(expandedTitle)
+
+ val expandedTitleMarginTop = when (val layoutParams = expandedTitle.layoutParams) {
+ is MarginLayoutParams -> layoutParams.topMargin
+ else -> 0
+ }
+
+ expandedTitleHeight = expandedTitle.height.toFloat()
+
+ scaleInterpolation.start = expandedTitleHeight / maxOf(1.0f, titleHeight)
+ xOffsetInterpolation.start = x
+ yOffsetInterpolation.start = y
+
+ scrollInterpolation.end = expandedTitleHeight + expandedTitleMarginTop
+ }
+
+ private val expandedTitle = parentView.findViewById<View>(R.id.expanded_title).apply {
+ addOnLayoutChangeListener(expandedTitleLayoutListener)
+ visibility = View.INVISIBLE
+ }
+
+ private val titleLayoutListener: LayoutListener = LayoutListener() {
+ val (x, y) = calculateViewCoordinates(title)
+
+ titleWidth = title.width.toFloat()
+ titleHeight = title.height.toFloat()
+
+ scaleInterpolation.start = expandedTitleHeight / maxOf(1.0f, titleHeight)
+ scaleInterpolation.end = collapsedTitleHeight / maxOf(1.0f, titleHeight)
+ xOffsetInterpolation.reference = x
+ yOffsetInterpolation.reference = y
+ }
+
+ private val title = parentView.findViewById<View>(R.id.title).apply {
+ addOnLayoutChangeListener(titleLayoutListener)
+
+ // Setting the scale pivot point to the left corner simplifies the calculations
+ pivotX = 0.0f
+ pivotY = 0.0f
+ }
+
+ private val scrollAreaLayoutListener: LayoutListener = LayoutListener() {
+ scrollOffset = scrollArea.scrollY.toFloat()
+ }
+
+ private val scrollArea = parentView.findViewById<ListenableScrollView>(R.id.scroll_area).apply {
+ onScrollListener = { _, top, _, _ ->
+ scrollOffset = top.toFloat()
+ update()
+ }
+
+ addOnLayoutChangeListener(scrollAreaLayoutListener)
+ }
+
+ private var scrollOffsetUpdated = false
+ get() {
+ if (field == true) {
+ field = false
+ return true
+ } else {
+ return false
+ }
+ }
+
+ private var collapsedTitleHeight = 0.0f
+ private var expandedTitleHeight = 0.0f
+ private var titleWidth = 0.0f
+ private var titleHeight = 0.0f
+
+ private var scrollOffset: Float by observable(0.0f) { _, old, new ->
+ if (scrollOffsetUpdated == false && old != new) {
+ scrollOffsetUpdated = true
+ }
+ }
+
+ init {
+ update()
+ }
+
+ fun onDestroy() {
+ scrollArea.onScrollListener = null
+ scrollArea.removeOnLayoutChangeListener(scrollAreaLayoutListener)
+
+ collapsedTitle.removeOnLayoutChangeListener(collapsedTitleLayoutListener)
+ expandedTitle.removeOnLayoutChangeListener(expandedTitleLayoutListener)
+ title.removeOnLayoutChangeListener(titleLayoutListener)
+ }
+
+ private fun update() {
+ val shouldUpdate =
+ scrollOffsetUpdated ||
+ scaleInterpolation.updated ||
+ xOffsetInterpolation.updated ||
+ yOffsetInterpolation.updated
+
+ if (shouldUpdate) {
+ val progress = maxOf(0.0f, minOf(1.0f, scrollInterpolation.progress(scrollOffset)))
+
+ val scale = scaleInterpolation.interpolate(progress)
+ val offsetX = xOffsetInterpolation.interpolate(progress)
+ val offsetY = yOffsetInterpolation.interpolate(progress)
+
+ title.apply {
+ scaleX = scale
+ scaleY = scale
+ translationX = offsetX
+ translationY = offsetY
+ }
+ }
+ }
+
+ private fun calculateViewCoordinates(view: View): Pair<Float, Float> {
+ var currentView = view
+ var x = 0.0f
+ var y = 0.0f
+
+ while (currentView != parentView) {
+ val parent = currentView.parent
+
+ x += currentView.x - currentView.translationX
+ y += currentView.y - currentView.translationY
+
+ if (parent is View) {
+ currentView = parent
+ } else {
+ break
+ }
+ }
+
+ return Pair(x, y)
+ }
+}