summaryrefslogtreecommitdiffhomepage
path: root/android/src
diff options
context:
space:
mode:
Diffstat (limited to 'android/src')
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListAdapter.kt14
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/ui/CollapsibleTitleController.kt69
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/ui/SelectLocationFragment.kt133
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/CustomRecyclerView.kt38
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/ListenableScrollView.kt10
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/util/AdapterWithHeader.kt122
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/util/HeaderOrHolder.kt9
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/util/ListenableScrollableView.kt8
-rw-r--r--android/src/main/res/layout/select_location.xml83
-rw-r--r--android/src/main/res/layout/select_location_header.xml33
10 files changed, 415 insertions, 104 deletions
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListAdapter.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListAdapter.kt
index 699aad1d66..a4f521be3d 100644
--- a/android/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListAdapter.kt
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListAdapter.kt
@@ -52,11 +52,17 @@ class RelayListAdapter(private val resources: Resources) : Adapter<RelayItemHold
override fun getItemCount() =
relayList?.countries?.map { country -> country.visibleItemCount }?.sum() ?: 0
- fun onRelayListChange(relayList: RelayList, selectedItem: RelayItem?) {
- this.relayList = relayList
- this.selectedItem = selectedItem
+ fun onRelayListChange(newRelayList: RelayList, newSelectedItem: RelayItem?) {
+ val initializedRelayList = relayList == null
- notifyDataSetChanged()
+ relayList = newRelayList
+ selectedItem = newSelectedItem
+
+ if (initializedRelayList) {
+ notifyItemRangeInserted(0, getItemCount())
+ } else {
+ notifyDataSetChanged()
+ }
}
fun selectItem(item: RelayItem?, holder: RelayItemHolder?) {
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/CollapsibleTitleController.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/CollapsibleTitleController.kt
index ec5a4549b1..dcde70b923 100644
--- a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/CollapsibleTitleController.kt
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/CollapsibleTitleController.kt
@@ -5,16 +5,16 @@ 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
+import net.mullvad.mullvadvpn.util.ListenableScrollableView
// 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
+// 1. A scroll area `View` with the `scrollAreaId` that implements `ListenableScrollableView`, 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
+// 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
@@ -23,8 +23,8 @@ import net.mullvad.mullvadvpn.util.LinearInterpolation
// 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 {
+class CollapsibleTitleController(val parentView: View, scrollAreaId: Int = R.id.scroll_area) {
+ private inner class LayoutListener(val listener: (View) -> Unit) : OnLayoutChangeListener {
override fun onLayoutChange(
view: View,
left: Int,
@@ -36,7 +36,7 @@ class CollapsibleTitleController(val parentView: View) {
oldRight: Int,
oldBottom: Int
) {
- listener.invoke()
+ listener.invoke(view)
update()
}
}
@@ -46,7 +46,7 @@ class CollapsibleTitleController(val parentView: View) {
private val xOffsetInterpolation = LinearInterpolation()
private val yOffsetInterpolation = LinearInterpolation()
- private val collapsedTitleLayoutListener: LayoutListener = LayoutListener() {
+ private val collapsedTitleLayoutListener: LayoutListener = LayoutListener() { collapsedTitle ->
val (x, y) = calculateViewCoordinates(collapsedTitle)
collapsedTitleHeight = collapsedTitle.height.toFloat()
@@ -56,12 +56,12 @@ class CollapsibleTitleController(val parentView: View) {
yOffsetInterpolation.end = y
}
- private val collapsedTitle = parentView.findViewById<View>(R.id.collapsed_title).apply {
+ private val collapsedTitleView = parentView.findViewById<View>(R.id.collapsed_title).apply {
addOnLayoutChangeListener(collapsedTitleLayoutListener)
visibility = View.INVISIBLE
}
- private val expandedTitleLayoutListener: LayoutListener = LayoutListener() {
+ private val expandedTitleLayoutListener: LayoutListener = LayoutListener() { expandedTitle ->
val (x, y) = calculateViewCoordinates(expandedTitle)
val expandedTitleMarginTop = when (val layoutParams = expandedTitle.layoutParams) {
@@ -78,12 +78,7 @@ class CollapsibleTitleController(val parentView: View) {
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() {
+ private val titleLayoutListener: LayoutListener = LayoutListener() { title ->
val (x, y) = calculateViewCoordinates(title)
titleWidth = title.width.toFloat()
@@ -95,7 +90,7 @@ class CollapsibleTitleController(val parentView: View) {
yOffsetInterpolation.reference = y
}
- private val title = parentView.findViewById<View>(R.id.title).apply {
+ private val titleView = parentView.findViewById<View>(R.id.title).apply {
addOnLayoutChangeListener(titleLayoutListener)
// Setting the scale pivot point to the left corner simplifies the calculations
@@ -104,16 +99,20 @@ class CollapsibleTitleController(val parentView: View) {
}
private val scrollAreaLayoutListener: LayoutListener = LayoutListener() {
- scrollOffset = scrollArea.scrollY.toFloat()
+ scrollOffset = scrollArea.verticalScrollOffset.toFloat()
}
- private val scrollArea = parentView.findViewById<ListenableScrollView>(R.id.scroll_area).apply {
- onScrollListener = { _, top, _, _ ->
+ private val scrollArea = parentView.findViewById<View>(scrollAreaId).let { view ->
+ val scrollableView = view as ListenableScrollableView
+
+ view.addOnLayoutChangeListener(scrollAreaLayoutListener)
+
+ scrollableView.onScrollListener = { _, top, _, _ ->
scrollOffset = top.toFloat()
update()
}
- addOnLayoutChangeListener(scrollAreaLayoutListener)
+ scrollableView
}
private var scrollOffsetUpdated = false
@@ -140,17 +139,27 @@ class CollapsibleTitleController(val parentView: View) {
val fullCollapseScrollOffset: Float
get() = scrollInterpolation.end
+ var expandedTitleView by observable<View?>(null) { _, oldView, newView ->
+ oldView?.removeOnLayoutChangeListener(expandedTitleLayoutListener)
+ newView?.apply {
+ addOnLayoutChangeListener(expandedTitleLayoutListener)
+ expandedTitleLayoutListener.listener(this)
+ visibility = View.INVISIBLE
+ }
+ }
+
init {
+ expandedTitleView = parentView.findViewById<View>(R.id.expanded_title)
update()
}
fun onDestroy() {
scrollArea.onScrollListener = null
- scrollArea.removeOnLayoutChangeListener(scrollAreaLayoutListener)
+ (scrollArea as View).removeOnLayoutChangeListener(scrollAreaLayoutListener)
- collapsedTitle.removeOnLayoutChangeListener(collapsedTitleLayoutListener)
- expandedTitle.removeOnLayoutChangeListener(expandedTitleLayoutListener)
- title.removeOnLayoutChangeListener(titleLayoutListener)
+ collapsedTitleView.removeOnLayoutChangeListener(collapsedTitleLayoutListener)
+ expandedTitleView?.removeOnLayoutChangeListener(expandedTitleLayoutListener)
+ titleView.removeOnLayoutChangeListener(titleLayoutListener)
}
private fun update() {
@@ -161,13 +170,17 @@ class CollapsibleTitleController(val parentView: View) {
yOffsetInterpolation.updated
if (shouldUpdate) {
- val progress = maxOf(0.0f, minOf(1.0f, scrollInterpolation.progress(scrollOffset)))
+ val progress = if (expandedTitleView != null) {
+ maxOf(0.0f, minOf(1.0f, scrollInterpolation.progress(scrollOffset)))
+ } else {
+ 1.0f
+ }
val scale = scaleInterpolation.interpolate(progress)
val offsetX = xOffsetInterpolation.interpolate(progress)
val offsetY = yOffsetInterpolation.interpolate(progress)
- title.apply {
+ titleView.apply {
scaleX = scale
scaleY = scale
translationX = offsetX
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/SelectLocationFragment.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/SelectLocationFragment.kt
index 5b95c9d296..1ba8948375 100644
--- a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/SelectLocationFragment.kt
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/SelectLocationFragment.kt
@@ -3,12 +3,14 @@ package net.mullvad.mullvadvpn.ui
import android.content.Context
import android.os.Bundle
import android.support.v7.widget.LinearLayoutManager
-import android.support.v7.widget.RecyclerView
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
+import android.view.animation.Animation
+import android.view.animation.Animation.AnimationListener
+import android.view.animation.AnimationUtils
import android.widget.ImageButton
-import android.widget.ViewSwitcher
+import kotlinx.coroutines.CompletableDeferred
import net.mullvad.mullvadvpn.R
import net.mullvad.mullvadvpn.model.Constraint
import net.mullvad.mullvadvpn.model.KeygenEvent
@@ -19,10 +21,21 @@ import net.mullvad.mullvadvpn.relaylist.RelayItem
import net.mullvad.mullvadvpn.relaylist.RelayItemDividerDecoration
import net.mullvad.mullvadvpn.relaylist.RelayList
import net.mullvad.mullvadvpn.relaylist.RelayListAdapter
+import net.mullvad.mullvadvpn.ui.widget.CustomRecyclerView
+import net.mullvad.mullvadvpn.util.AdapterWithHeader
class SelectLocationFragment : ServiceDependentFragment(OnNoService.GoToLaunchScreen) {
+ private enum class RelayListState {
+ Initializing,
+ Loading,
+ Visible,
+ }
+
private lateinit var relayListAdapter: RelayListAdapter
- private lateinit var relayListContainer: ViewSwitcher
+ private lateinit var titleController: CollapsibleTitleController
+
+ private var loadingSpinner = CompletableDeferred<View>()
+ private var relayListState = RelayListState.Initializing
override fun onAttach(context: Context) {
super.onAttach(context)
@@ -50,37 +63,73 @@ class SelectLocationFragment : ServiceDependentFragment(OnNoService.GoToLaunchSc
view.findViewById<ImageButton>(R.id.close).setOnClickListener { close() }
- relayListContainer = view.findViewById<ViewSwitcher>(R.id.relay_list_container)
- relayListContainer.showNext()
+ titleController = CollapsibleTitleController(view, R.id.relay_list)
- configureRelayList(view.findViewById<RecyclerView>(R.id.relay_list))
+ view.findViewById<CustomRecyclerView>(R.id.relay_list).apply {
+ layoutManager = LinearLayoutManager(parentActivity)
+
+ adapter = AdapterWithHeader(relayListAdapter, R.layout.select_location_header).apply {
+ onHeaderAvailable = { headerView ->
+ initializeLoadingSpinner(headerView)
+ titleController.expandedTitleView = headerView.findViewById(R.id.expanded_title)
+ }
+ }
+
+ addItemDecoration(RelayItemDividerDecoration(parentActivity))
+ }
return view
}
override fun onSafelyResume() {
+ // If the relay list is immediately available, setting the listener will cause it to be
+ // called right away, while the state is still Initializing. In that case we can skip
+ // showing the spinner animation and go directly to the Visible state.
+ //
+ // If it's not immediately available, then when the listener is called later the state will
+ // have changed to Loading, and an animation from the spinner to the new relay items will be
+ // shown.
+ //
+ // If the state is ready, it means that the relay list has already been shown, and we can
+ // update it in place.
relayListListener.onRelayListChange = { relayList, selectedItem ->
- jobTracker.newUiJob("updateRelayList") {
- updateRelayList(relayList, selectedItem)
+ when (relayListState) {
+ RelayListState.Initializing -> {
+ jobTracker.newUiJob("updateRelayList") {
+ updateRelayList(relayList, selectedItem)
+ }
+
+ relayListState = RelayListState.Visible
+ }
+ RelayListState.Loading -> {
+ jobTracker.newUiJob("updateRelayList") {
+ animateRelayListInitialization(relayList, selectedItem)
+ }
+ }
+ RelayListState.Visible -> {
+ jobTracker.newUiJob("updateRelayList") {
+ updateRelayList(relayList, selectedItem)
+ }
+ }
}
}
+
+ if (relayListState == RelayListState.Initializing) {
+ relayListState = RelayListState.Loading
+ }
}
override fun onSafelyPause() {
relayListListener.onRelayListChange = null
}
- fun close() {
- activity?.onBackPressed()
+ override fun onDestroyView() {
+ super.onDestroyView()
+ titleController.onDestroy()
}
- private fun configureRelayList(relayList: RecyclerView) {
- relayList.apply {
- layoutManager = LinearLayoutManager(context!!)
- adapter = relayListAdapter
-
- addItemDecoration(RelayItemDividerDecoration(context!!))
- }
+ fun close() {
+ activity?.onBackPressed()
}
private fun updateLocationConstraint(relayItem: RelayItem?) {
@@ -92,12 +141,6 @@ class SelectLocationFragment : ServiceDependentFragment(OnNoService.GoToLaunchSc
private fun updateRelayList(relayList: RelayList, selectedItem: RelayItem?) {
relayListAdapter.onRelayListChange(relayList, selectedItem)
-
- if (relayList.countries.isEmpty()) {
- relayListContainer.showPrevious()
- } else if (relayListContainer.displayedChild == 0) {
- relayListContainer.showNext()
- }
}
private fun maybeConnect() {
@@ -107,4 +150,48 @@ class SelectLocationFragment : ServiceDependentFragment(OnNoService.GoToLaunchSc
connectionProxy.connect()
}
}
+
+ private fun initializeLoadingSpinner(parentView: View) {
+ val spinner = parentView.findViewById<View>(R.id.loading_spinner)
+
+ if (relayListState == RelayListState.Visible) {
+ // Because this method is executed inside a layout pass, hiding the spinner needs to be
+ // done in a new job so that it is executed after the layout pass finishes and can
+ // therefore schedule a new layout
+ jobTracker.newUiJob("hideLoadingSpinner") {
+ spinner.visibility = View.GONE
+ }
+ }
+
+ loadingSpinner.complete(spinner)
+ }
+
+ // Smoothly fade out the spinner before showing the relay list items.
+ private suspend fun animateRelayListInitialization(
+ relayList: RelayList,
+ selectedItem: RelayItem?
+ ) {
+ val animationFinished = CompletableDeferred<Unit>()
+ val animationListener = object : AnimationListener {
+ override fun onAnimationEnd(animation: Animation) {
+ animationFinished.complete(Unit)
+ }
+
+ override fun onAnimationStart(animation: Animation) {}
+ override fun onAnimationRepeat(animation: Animation) {}
+ }
+
+ val fadeOut = AnimationUtils.loadAnimation(parentActivity, R.anim.fade_out).apply {
+ setAnimationListener(animationListener)
+ }
+
+ loadingSpinner.await().let { spinner ->
+ spinner.startAnimation(fadeOut)
+
+ animationFinished.await()
+
+ spinner.visibility = View.GONE
+ updateRelayList(relayList, selectedItem)
+ }
+ }
}
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/CustomRecyclerView.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/CustomRecyclerView.kt
new file mode 100644
index 0000000000..710bb834d0
--- /dev/null
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/CustomRecyclerView.kt
@@ -0,0 +1,38 @@
+package net.mullvad.mullvadvpn.ui.widget
+
+import android.content.Context
+import android.support.v7.widget.RecyclerView
+import android.util.AttributeSet
+import net.mullvad.mullvadvpn.util.ListenableScrollableView
+
+class CustomRecyclerView : RecyclerView, ListenableScrollableView {
+ override var horizontalScrollOffset = 0
+ override var verticalScrollOffset = 0
+
+ override 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) {
+ }
+
+ override fun onScrolled(horizontalDelta: Int, verticalDelta: Int) {
+ super.onScrolled(horizontalDelta, verticalDelta)
+
+ val oldHorizontalScrollOffset = horizontalScrollOffset
+ val oldVerticalScrollOffset = verticalScrollOffset
+
+ horizontalScrollOffset += horizontalDelta
+ verticalScrollOffset += verticalDelta
+
+ onScrollListener?.invoke(
+ horizontalScrollOffset,
+ verticalScrollOffset,
+ oldHorizontalScrollOffset,
+ oldVerticalScrollOffset
+ )
+ }
+}
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
index 303527c3d4..b436df903a 100644
--- a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/ListenableScrollView.kt
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/ListenableScrollView.kt
@@ -3,9 +3,15 @@ package net.mullvad.mullvadvpn.ui.widget
import android.content.Context
import android.util.AttributeSet
import android.widget.ScrollView
+import net.mullvad.mullvadvpn.util.ListenableScrollableView
-class ListenableScrollView : ScrollView {
- var onScrollListener: ((Int, Int, Int, Int) -> Unit)? = null
+class ListenableScrollView : ScrollView, ListenableScrollableView {
+ override val horizontalScrollOffset
+ get() = scrollX
+ override val verticalScrollOffset
+ get() = scrollY
+
+ override var onScrollListener: ((Int, Int, Int, Int) -> Unit)? = null
constructor(context: Context) : super(context) {}
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/util/AdapterWithHeader.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/util/AdapterWithHeader.kt
new file mode 100644
index 0000000000..379d58f758
--- /dev/null
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/util/AdapterWithHeader.kt
@@ -0,0 +1,122 @@
+package net.mullvad.mullvadvpn.util
+
+import android.support.v7.widget.RecyclerView.Adapter
+import android.support.v7.widget.RecyclerView.AdapterDataObserver
+import android.support.v7.widget.RecyclerView.ViewHolder
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import kotlin.properties.Delegates.observable
+
+class AdapterWithHeader<H : ViewHolder>(
+ val adapter: Adapter<H>,
+ val headerLayoutId: Int
+) : Adapter<HeaderOrHolder<H>>() {
+ private val observer = object : AdapterDataObserver() {
+ override fun onChanged() {
+ notifyDataSetChanged()
+ }
+
+ override fun onItemRangeChanged(start: Int, count: Int) {
+ notifyItemRangeChanged(start + 1, count)
+ }
+
+ override fun onItemRangeChanged(start: Int, count: Int, payload: Any?) {
+ notifyItemRangeChanged(start + 1, count, payload)
+ }
+
+ override fun onItemRangeInserted(start: Int, count: Int) {
+ notifyItemRangeInserted(start + 1, count)
+ }
+
+ override fun onItemRangeMoved(from: Int, to: Int, count: Int) {
+ if (from == to) {
+ notifyItemRangeChanged(from + 1, count)
+ } else {
+ val sourceStart = from + 1
+ val sourceEnd = sourceStart + count
+ val destinationStart = to + 1
+ val destinationEnd = destinationStart + count
+
+ val ascendingIndices =
+ (sourceStart..sourceEnd).zip(destinationStart..destinationEnd)
+
+ val indices = if (from < to) {
+ ascendingIndices.asReversed()
+ } else {
+ ascendingIndices
+ }
+
+ for ((source, destination) in indices) {
+ notifyItemMoved(source, destination)
+ }
+ }
+ }
+
+ override fun onItemRangeRemoved(start: Int, count: Int) {
+ notifyItemRangeRemoved(start + 1, count)
+ }
+ }
+
+ private var headerView: View? by observable<View?>(null) { _, _, newView ->
+ newView?.let { view -> onHeaderAvailable?.invoke(view) }
+ }
+
+ var onHeaderAvailable by observable<((View) -> Unit)?>(null) { _, _, listener ->
+ headerView?.let { header -> listener?.invoke(header) }
+ }
+
+ init {
+ adapter.registerAdapterDataObserver(observer)
+ }
+
+ override fun getItemCount() = adapter.itemCount + 1
+
+ override fun getItemId(position: Int): Long {
+ if (position == 0) {
+ return 0L
+ } else {
+ return adapter.getItemId(position - 1) + 1
+ }
+ }
+
+ override fun getItemViewType(position: Int): Int {
+ if (position == 0) {
+ return 0
+ } else {
+ return adapter.getItemViewType(position - 1) + 1
+ }
+ }
+
+ override fun onBindViewHolder(holder: HeaderOrHolder<H>, position: Int) {
+ when (holder) {
+ is HeaderOrHolder.Header -> {
+ if (position != 0) {
+ throw IllegalArgumentException("Adapter position is not for the header")
+ }
+ }
+ is HeaderOrHolder.Holder -> {
+ if (position > 0) {
+ adapter.onBindViewHolder(holder.holder, position - 1)
+ } else {
+ throw IllegalArgumentException("Adapter position is for the header")
+ }
+ }
+ }
+ }
+
+ override fun onCreateViewHolder(parentView: ViewGroup, viewType: Int): HeaderOrHolder<H> {
+ if (viewType == 0) {
+ val inflater = LayoutInflater.from(parentView.context)
+ val view = inflater.inflate(headerLayoutId, parentView, false)
+
+ headerView = view
+
+ return HeaderOrHolder.Header(view)
+ } else {
+ val holder = adapter.onCreateViewHolder(parentView, viewType - 1)
+
+ return HeaderOrHolder.Holder(holder)
+ }
+ }
+}
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/util/HeaderOrHolder.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/util/HeaderOrHolder.kt
new file mode 100644
index 0000000000..631d69100e
--- /dev/null
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/util/HeaderOrHolder.kt
@@ -0,0 +1,9 @@
+package net.mullvad.mullvadvpn.util
+
+import android.support.v7.widget.RecyclerView.ViewHolder
+import android.view.View
+
+sealed class HeaderOrHolder<H : ViewHolder>(itemView: View) : ViewHolder(itemView) {
+ class Header<H : ViewHolder>(headerView: View) : HeaderOrHolder<H>(headerView)
+ class Holder<H : ViewHolder>(val holder: H) : HeaderOrHolder<H>(holder.itemView)
+}
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/util/ListenableScrollableView.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/util/ListenableScrollableView.kt
new file mode 100644
index 0000000000..61deecacb2
--- /dev/null
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/util/ListenableScrollableView.kt
@@ -0,0 +1,8 @@
+package net.mullvad.mullvadvpn.util
+
+interface ListenableScrollableView {
+ val horizontalScrollOffset: Int
+ val verticalScrollOffset: Int
+
+ var onScrollListener: ((Int, Int, Int, Int) -> Unit)?
+}
diff --git a/android/src/main/res/layout/select_location.xml b/android/src/main/res/layout/select_location.xml
index 50c1d10643..cf3e905876 100644
--- a/android/src/main/res/layout/select_location.xml
+++ b/android/src/main/res/layout/select_location.xml
@@ -1,51 +1,40 @@
-<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:background="@color/darkBlue"
- android:orientation="vertical"
- android:gravity="left"
- android:elevation="1dp">
- <ImageButton android:id="@+id/close"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_weight="0"
- android:padding="12dp"
- android:background="?android:attr/selectableItemBackground"
- android:src="@drawable/icon_close" />
- <TextView android:layout_width="wrap_content"
+<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:background="@color/darkBlue"
+ android:gravity="left"
+ android:elevation="1dp">
+ <TextView android:id="@+id/title"
+ android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:layout_weight="0"
- android:layout_marginVertical="4dp"
- android:layout_marginHorizontal="24dp"
android:textColor="@color/white"
- android:textSize="32sp"
+ android:textSize="16sp"
android:textStyle="bold"
android:text="@string/select_location" />
- <TextView android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_weight="0"
- android:layout_marginHorizontal="24dp"
- android:layout_marginBottom="24dp"
- android:textColor="@color/white60"
- android:textSize="13sp"
- android:text="@string/select_location_description" />
- <ViewSwitcher android:id="@+id/relay_list_container"
- android:layout_width="match_parent"
- android:layout_height="0dp"
- android:layout_weight="1"
- android:inAnimation="@anim/fade_in"
- android:outAnimation="@anim/fade_out">
- <ProgressBar android:layout_width="60dp"
- android:layout_height="60dp"
- android:layout_gravity="center"
- android:indeterminate="true"
- android:indeterminateOnly="true"
- android:indeterminateDuration="600"
- android:indeterminateDrawable="@drawable/icon_spinner"
- android:visibility="invisible" />
- <android.support.v7.widget.RecyclerView android:id="@+id/relay_list"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:scrollbars="vertical" />
- </ViewSwitcher>
-</LinearLayout>
+ <LinearLayout android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:orientation="vertical">
+ <FrameLayout android:layout_width="match_parent"
+ android:layout_height="wrap_content">
+ <ImageButton android:id="@+id/close"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:padding="12dp"
+ android:background="?android:attr/selectableItemBackground"
+ android:src="@drawable/icon_close" />
+ <TextView android:id="@+id/collapsed_title"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginHorizontal="4dp"
+ android:layout_gravity="center"
+ android:textColor="@color/white"
+ android:textSize="16sp"
+ android:textStyle="bold"
+ android:text="@string/select_location" />
+ </FrameLayout>
+ <net.mullvad.mullvadvpn.ui.widget.CustomRecyclerView android:id="@+id/relay_list"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:scrollbars="vertical" />
+ </LinearLayout>
+</FrameLayout>
diff --git a/android/src/main/res/layout/select_location_header.xml b/android/src/main/res/layout/select_location_header.xml
new file mode 100644
index 0000000000..85b160ad9f
--- /dev/null
+++ b/android/src/main/res/layout/select_location_header.xml
@@ -0,0 +1,33 @@
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:gravity="left">
+ <TextView android:id="@+id/expanded_title"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_weight="0"
+ android:layout_marginVertical="4dp"
+ android:layout_marginHorizontal="24dp"
+ android:textColor="@color/white"
+ android:textSize="32sp"
+ android:textStyle="bold"
+ android:text="@string/select_location" />
+ <TextView android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_weight="0"
+ android:layout_marginHorizontal="24dp"
+ android:layout_marginBottom="24dp"
+ android:textColor="@color/white60"
+ android:textSize="13sp"
+ android:text="@string/select_location_description" />
+ <ProgressBar android:id="@+id/loading_spinner"
+ android:layout_width="60dp"
+ android:layout_height="60dp"
+ android:layout_gravity="center"
+ android:indeterminate="true"
+ android:indeterminateOnly="true"
+ android:indeterminateDuration="600"
+ android:indeterminateDrawable="@drawable/icon_spinner"
+ android:visibility="visible" />
+</LinearLayout>