summaryrefslogtreecommitdiffhomepage
path: root/android/src/main/kotlin
diff options
context:
space:
mode:
authorJanito Vaqueiro Ferreira Filho <janito@mullvad.net>2020-06-02 13:46:59 -0300
committerJanito Vaqueiro Ferreira Filho <janito@mullvad.net>2020-06-02 13:46:59 -0300
commitb5a321c14e6ccedb69eaace01ced9c16da8b95c7 (patch)
tree1067a0abbaae698517cb8d0651c71f82caeb3f25 /android/src/main/kotlin
parent8587db2b45354918f8b04817407944bc3dc1ab38 (diff)
parent39091569768e900a51a4e2324246dbeee5659176 (diff)
downloadmullvadvpn-b5a321c14e6ccedb69eaace01ced9c16da8b95c7.tar.xz
mullvadvpn-b5a321c14e6ccedb69eaace01ced9c16da8b95c7.zip
Merge branch 'settings-scroll-animation'
Diffstat (limited to 'android/src/main/kotlin')
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/ui/AccountFragment.kt7
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/ui/AdvancedFragment.kt4
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/ui/CollapsibleTitleController.kt199
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/ui/PreferencesFragment.kt4
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/ui/ProblemReportFragment.kt13
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/ui/SettingsFragment.kt7
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/ui/WireguardKeyFragment.kt7
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/ListenableScrollView.kt30
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/util/LinearInterpolation.kt46
9 files changed, 317 insertions, 0 deletions
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/AccountFragment.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/AccountFragment.kt
index f7606ea737..eae581a243 100644
--- a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/AccountFragment.kt
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/AccountFragment.kt
@@ -43,6 +43,7 @@ class AccountFragment : ServiceDependentFragment(OnNoService.GoBack) {
private lateinit var accountNumberView: CopyableInformationView
private lateinit var buyCreditButton: Button
private lateinit var redeemVoucherButton: Button
+ private lateinit var titleController: CollapsibleTitleController
override fun onSafelyCreateView(
inflater: LayoutInflater,
@@ -77,6 +78,8 @@ class AccountFragment : ServiceDependentFragment(OnNoService.GoBack) {
accountExpiryView = view.findViewById(R.id.account_expiry)
+ titleController = CollapsibleTitleController(view)
+
return view
}
@@ -112,6 +115,10 @@ class AccountFragment : ServiceDependentFragment(OnNoService.GoBack) {
accountCache.onAccountExpiryChange.unsubscribe(this)
}
+ override fun onSafelyDestroyView() {
+ titleController.onDestroy()
+ }
+
private fun checkForAddedTime() {
currentAccountExpiry?.let { expiry ->
oldAccountExpiry = expiry
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/AdvancedFragment.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/AdvancedFragment.kt
index 1a43098547..4f51012759 100644
--- a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/AdvancedFragment.kt
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/AdvancedFragment.kt
@@ -15,6 +15,7 @@ private const val MAX_MTU_VALUE = 1420
class AdvancedFragment : ServiceDependentFragment(OnNoService.GoBack) {
private lateinit var wireguardMtuInput: CellInput
private lateinit var wireguardKeysMenu: View
+ private lateinit var titleController: CollapsibleTitleController
override fun onSafelyCreateView(
inflater: LayoutInflater,
@@ -50,6 +51,8 @@ class AdvancedFragment : ServiceDependentFragment(OnNoService.GoBack) {
updateUi(settings)
}
+ titleController = CollapsibleTitleController(view)
+
return view
}
@@ -62,6 +65,7 @@ class AdvancedFragment : ServiceDependentFragment(OnNoService.GoBack) {
}
override fun onSafelyDestroyView() {
+ titleController.onDestroy()
settingsListener.unsubscribe(this)
}
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..ab0b01c92e
--- /dev/null
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/CollapsibleTitleController.kt
@@ -0,0 +1,199 @@
+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
+ }
+ }
+
+ val fullCollapseScrollOffset: Float
+ get() = scrollInterpolation.end
+
+ 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)
+ }
+}
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/PreferencesFragment.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/PreferencesFragment.kt
index 787ddf4d9e..f996994e03 100644
--- a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/PreferencesFragment.kt
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/PreferencesFragment.kt
@@ -10,6 +10,7 @@ import net.mullvad.mullvadvpn.model.Settings
class PreferencesFragment : ServiceDependentFragment(OnNoService.GoBack) {
private lateinit var allowLanToggle: CellSwitch
private lateinit var autoConnectToggle: CellSwitch
+ private lateinit var titleController: CollapsibleTitleController
override fun onSafelyCreateView(
inflater: LayoutInflater,
@@ -48,6 +49,8 @@ class PreferencesFragment : ServiceDependentFragment(OnNoService.GoBack) {
updateUi(settings)
}
+ titleController = CollapsibleTitleController(view)
+
return view
}
@@ -59,6 +62,7 @@ class PreferencesFragment : ServiceDependentFragment(OnNoService.GoBack) {
}
override fun onSafelyDestroyView() {
+ titleController.onDestroy()
settingsListener.unsubscribe(this)
}
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/ProblemReportFragment.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/ProblemReportFragment.kt
index b106e2925b..0cf2c61c84 100644
--- a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/ProblemReportFragment.kt
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/ProblemReportFragment.kt
@@ -10,6 +10,7 @@ import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.EditText
+import android.widget.ScrollView
import android.widget.TextView
import android.widget.ViewSwitcher
import kotlinx.coroutines.CompletableDeferred
@@ -39,6 +40,9 @@ class ProblemReportFragment : Fragment() {
private lateinit var editMessageButton: Button
private lateinit var tryAgainButton: Button
+ private lateinit var scrollArea: ScrollView
+ private lateinit var titleController: CollapsibleTitleController
+
override fun onAttach(context: Context) {
super.onAttach(context)
@@ -98,6 +102,9 @@ class ProblemReportFragment : Fragment() {
setSendButtonEnabled(!userMessageInput.text.isEmpty())
userMessageInput.addTextChangedListener(InputWatcher())
+ scrollArea = view.findViewById(R.id.scroll_area)
+ titleController = CollapsibleTitleController(view)
+
return view
}
@@ -106,6 +113,8 @@ class ProblemReportFragment : Fragment() {
problemReport.userMessage = userMessageInput.text.toString()
problemReport.deleteReportFile()
+ titleController.onDestroy()
+
super.onDestroyView()
}
@@ -189,6 +198,8 @@ class ProblemReportFragment : Fragment() {
sendStatusLabel.setText(R.string.sent)
sendDetailsLabel.setText(R.string.sent_thanks)
+
+ scrollArea.scrollTo(0, titleController.fullCollapseScrollOffset.toInt())
}
private fun showErrorScreen() {
@@ -203,6 +214,8 @@ class ProblemReportFragment : Fragment() {
editMessageButton.visibility = View.VISIBLE
tryAgainButton.visibility = View.VISIBLE
+
+ scrollArea.scrollTo(0, titleController.fullCollapseScrollOffset.toInt())
}
private fun setSendButtonEnabled(enabled: Boolean) {
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/SettingsFragment.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/SettingsFragment.kt
index e4955a44d0..edc8bf518a 100644
--- a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/SettingsFragment.kt
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/SettingsFragment.kt
@@ -22,6 +22,7 @@ class SettingsFragment : ServiceAwareFragment() {
private lateinit var preferencesMenu: View
private lateinit var advancedMenu: View
private lateinit var remainingTimeLabel: RemainingTimeLabel
+ private lateinit var titleController: CollapsibleTitleController
private var active = false
@@ -87,6 +88,7 @@ class SettingsFragment : ServiceAwareFragment() {
appVersionLabel = view.findViewById<TextView>(R.id.app_version_label)
appVersionFooter = view.findViewById(R.id.app_version_footer)
remainingTimeLabel = RemainingTimeLabel(parentActivity, view)
+ titleController = CollapsibleTitleController(view)
return view
}
@@ -110,6 +112,11 @@ class SettingsFragment : ServiceAwareFragment() {
super.onPause()
}
+ override fun onDestroyView() {
+ super.onDestroyView()
+ titleController.onDestroy()
+ }
+
private fun configureListeners() {
accountCache?.apply {
onAccountNumberChange.subscribe(this@SettingsFragment) { account ->
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/WireguardKeyFragment.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/WireguardKeyFragment.kt
index 067600fcfd..25537b76a5 100644
--- a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/WireguardKeyFragment.kt
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/WireguardKeyFragment.kt
@@ -32,6 +32,7 @@ class WireguardKeyFragment : ServiceDependentFragment(OnNoService.GoToLaunchScre
}
private lateinit var timeAgoFormatter: TimeAgoFormatter
+ private lateinit var titleController: CollapsibleTitleController
private var greenColor: Int = 0
private var redColor: Int = 0
@@ -135,6 +136,8 @@ class WireguardKeyFragment : ServiceDependentFragment(OnNoService.GoToLaunchScre
prepare(daemon, jobTracker)
}
+ titleController = CollapsibleTitleController(view)
+
return view
}
@@ -175,6 +178,10 @@ class WireguardKeyFragment : ServiceDependentFragment(OnNoService.GoToLaunchScre
}
}
+ override fun onSafelyDestroyView() {
+ titleController.onDestroy()
+ }
+
private fun updateKeySpinners() {
when (actionState) {
is ActionState.Generating -> {
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/ListenableScrollView.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/ListenableScrollView.kt
new file mode 100644
index 0000000000..95fdeebc63
--- /dev/null
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/ListenableScrollView.kt
@@ -0,0 +1,30 @@
+package net.mullvad.mullvadvpn.ui.widget
+
+import android.content.Context
+import android.util.AttributeSet
+import android.widget.ScrollView
+
+class ListenableScrollView : ScrollView {
+ var onScrollListener: ((Int, Int, Int, Int) -> Unit)? = null
+
+ constructor(context: Context) : super(context) {}
+
+ constructor(context: Context, attributes: AttributeSet) : super(context, attributes) {}
+
+ constructor(context: Context, attributes: AttributeSet, defaultStyleAttribute: Int) :
+ super(context, attributes, defaultStyleAttribute) {
+ }
+
+ constructor(
+ context: Context,
+ attributes: AttributeSet,
+ defaultStyleAttribute: Int,
+ defaultStyleResource: Int
+ ) : super(context, attributes, defaultStyleAttribute, defaultStyleResource) {
+ }
+
+ override fun onScrollChanged(left: Int, top: Int, oldLeft: Int, oldTop: Int) {
+ super.onScrollChanged(left, top, oldLeft, oldTop)
+ onScrollListener?.invoke(left, top, oldLeft, oldTop)
+ }
+}
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/util/LinearInterpolation.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/util/LinearInterpolation.kt
new file mode 100644
index 0000000000..ea0f21ad49
--- /dev/null
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/util/LinearInterpolation.kt
@@ -0,0 +1,46 @@
+package net.mullvad.mullvadvpn.util
+
+import kotlin.properties.Delegates.observable
+import kotlin.reflect.KProperty
+
+class LinearInterpolation {
+ private val observer = { property: KProperty<*>, oldValue: Float, newValue: Float ->
+ if (!updated && oldValue != newValue) {
+ updated = true
+ }
+ }
+
+ private val realStart
+ get() = start - reference
+
+ private val realEnd
+ get() = end - reference
+
+ var reference by observable(0.0f, observer)
+ var start by observable(0.0f, observer)
+ var end by observable(0.0f, observer)
+
+ var updated = true
+ get() {
+ if (field == true) {
+ field = false
+ return true
+ } else {
+ return false
+ }
+ }
+
+ fun interpolate(progress: Float): Float {
+ return progress * (realEnd - realStart) + realStart
+ }
+
+ fun progress(interpolation: Float): Float {
+ val length = realEnd - realStart
+
+ if (length == 0.0f) {
+ return 0.0f
+ }
+
+ return (interpolation - realStart) / length
+ }
+}