diff options
| author | Janito Vaqueiro Ferreira Filho <janito@mullvad.net> | 2020-06-02 13:46:59 -0300 |
|---|---|---|
| committer | Janito Vaqueiro Ferreira Filho <janito@mullvad.net> | 2020-06-02 13:46:59 -0300 |
| commit | b5a321c14e6ccedb69eaace01ced9c16da8b95c7 (patch) | |
| tree | 1067a0abbaae698517cb8d0651c71f82caeb3f25 /android/src/main/kotlin | |
| parent | 8587db2b45354918f8b04817407944bc3dc1ab38 (diff) | |
| parent | 39091569768e900a51a4e2324246dbeee5659176 (diff) | |
| download | mullvadvpn-b5a321c14e6ccedb69eaace01ced9c16da8b95c7.tar.xz mullvadvpn-b5a321c14e6ccedb69eaace01ced9c16da8b95c7.zip | |
Merge branch 'settings-scroll-animation'
Diffstat (limited to 'android/src/main/kotlin')
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 + } +} |
