diff options
4 files changed, 240 insertions, 0 deletions
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/CellSwitch.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/CellSwitch.kt new file mode 100644 index 0000000000..622ff38799 --- /dev/null +++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/CellSwitch.kt @@ -0,0 +1,202 @@ +package net.mullvad.mullvadvpn.ui + +import android.animation.ValueAnimator +import android.content.Context +import android.graphics.Paint.Style +import android.graphics.drawable.ShapeDrawable +import android.graphics.drawable.shapes.OvalShape +import android.util.AttributeSet +import android.view.GestureDetector +import android.view.GestureDetector.OnGestureListener +import android.view.Gravity +import android.view.MotionEvent +import android.widget.ImageView +import android.widget.LinearLayout +import net.mullvad.mullvadvpn.R + +class CellSwitch : LinearLayout { + enum class State { + ON, + OFF + } + + var state = State.OFF + set(value) { + if (field != value) { + field = value + animateToState() + } + } + + private val onColor = resources.getColor(R.color.green) + private val offColor = resources.getColor(R.color.red) + + private val knobSize = resources.getDimensionPixelSize(R.dimen.cell_switch_knob_size) + private val knobImage = ShapeDrawable(OvalShape()).apply { + paint.apply { + color = offColor + style = Style.FILL + } + + intrinsicWidth = knobSize + intrinsicHeight = knobSize + } + + private val knobView = ImageView(context).apply { + setImageDrawable(knobImage) + } + + private val knobAnimationDuration = 200L + private val knobMaxTranslation = + resources.getDimensionPixelOffset(R.dimen.cell_switch_knob_max_translation).toFloat() + + private val knobPosition: Float + get() = knobView.translationX / knobMaxTranslation + + private val positionAnimation = ValueAnimator.ofFloat(0f, knobMaxTranslation).apply { + addUpdateListener { animation -> + knobView.translationX = animation.animatedValue as Float + } + + duration = knobAnimationDuration + } + + private val colorAnimation = ValueAnimator.ofArgb(offColor, onColor).apply { + addUpdateListener { animation -> + knobImage.paint.color = animation.animatedValue as Int + knobImage.invalidateSelf() + } + + duration = knobAnimationDuration + } + + private val gestureListener = object : OnGestureListener { + private var isScrolling: Boolean = false + private var scrollPosition: Float = 0f + + override fun onDown(event: MotionEvent): Boolean { + scrollPosition = knobView.translationX + return true + } + + override fun onFling( + downEvent: MotionEvent, + upEvent: MotionEvent, + velocityX: Float, + velocityY: Float + ): Boolean { + if (velocityX > 0f) { + state = State.ON + } else if (velocityX < 0f) { + state = State.OFF + } + + return true + } + + override fun onLongPress(event: MotionEvent) {} + + override fun onScroll( + downEvent: MotionEvent, + moveEvent: MotionEvent, + distanceX: Float, + distanceY: Float + ): Boolean { + isScrolling = true + scrollPosition -= distanceX + + var fraction = scrollPosition / knobMaxTranslation + val playTime = (fraction * knobAnimationDuration).toLong() + + colorAnimation.pause() + positionAnimation.pause() + + colorAnimation.currentPlayTime = playTime + positionAnimation.currentPlayTime = playTime + + return true + } + + override fun onShowPress(event: MotionEvent) {} + + override fun onSingleTapUp(event: MotionEvent): Boolean { + when (state) { + State.ON -> state = State.OFF + State.OFF -> state = State.ON + } + + return true + } + + fun onUp(): Boolean { + if (!isScrolling) { + return false + } + + if (knobPosition <= 0.5f) { + state = State.OFF + } else { + state = State.ON + } + + isScrolling = false + scrollPosition = 0f + + return true + } + } + + private val gestureDetector = GestureDetector(context, gestureListener) + + 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) {} + + init { + setBackground(resources.getDrawable(R.drawable.cell_switch_background, null)) + addView(knobView, LinearLayout.LayoutParams(knobSize, knobSize).apply { + gravity = Gravity.CENTER_VERTICAL + leftMargin = resources.getDimensionPixelSize(R.dimen.cell_switch_knob_margin) + }) + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + if (gestureDetector.onTouchEvent(event)) { + return true + } else if (event.actionMasked == MotionEvent.ACTION_UP) { + return gestureListener.onUp() + } + + return super.onTouchEvent(event) + } + + private fun animateToState() { + var playTime = (knobPosition * knobAnimationDuration).toLong() + + when (state) { + State.ON -> { + colorAnimation.start() + positionAnimation.start() + } + State.OFF -> { + colorAnimation.reverse() + positionAnimation.reverse() + + playTime = knobAnimationDuration - playTime + } + } + + colorAnimation.currentPlayTime = playTime + positionAnimation.currentPlayTime = playTime + } +} diff --git a/android/src/main/res/drawable/cell_switch_background.xml b/android/src/main/res/drawable/cell_switch_background.xml new file mode 100644 index 0000000000..71f6fe0802 --- /dev/null +++ b/android/src/main/res/drawable/cell_switch_background.xml @@ -0,0 +1,27 @@ +<?xml version="1.0" encoding="utf-8"?> +<selector + xmlns:android="http://schemas.android.com/apk/res/android" + android:shape="rectangle" + > + <item android:state_enabled="false"> + <shape android:shape="rectangle"> + <corners android:radius="@dimen/cell_switch_border_radius"/> + <stroke android:color="@color/white20" android:width="2dp"/> + <size + android:width="@dimen/cell_switch_width" + android:height="@dimen/cell_switch_height" + /> + </shape> + </item> + + <item android:state_enabled="true"> + <shape android:shape="rectangle"> + <corners android:radius="@dimen/cell_switch_border_radius"/> + <stroke android:color="@color/white" android:width="2dp"/> + <size + android:width="@dimen/cell_switch_width" + android:height="@dimen/cell_switch_height" + /> + </shape> + </item> +</selector> diff --git a/android/src/main/res/layout/preferences.xml b/android/src/main/res/layout/preferences.xml index 38089ce7e4..870a9a5206 100644 --- a/android/src/main/res/layout/preferences.xml +++ b/android/src/main/res/layout/preferences.xml @@ -60,6 +60,11 @@ android:textStyle="bold" android:text="@string/local_network_sharing" /> + <net.mullvad.mullvadvpn.ui.CellSwitch + android:layout_width="52dp" + android:layout_height="32dp" + android:layout_weight="0" + /> </LinearLayout> <TextView android:id="@+id/allow_lan_footer" android:layout_width="match_parent" diff --git a/android/src/main/res/values/dimensions.xml b/android/src/main/res/values/dimensions.xml index 800e1ed0ac..5d106210ea 100644 --- a/android/src/main/res/values/dimensions.xml +++ b/android/src/main/res/values/dimensions.xml @@ -6,4 +6,10 @@ <dimen name="account_input_corner_radius">4dp</dimen> <dimen name="normal_button_height">44dp</dimen> <dimen name="tall_button_height">64dp</dimen> + <dimen name="cell_switch_border_radius">16dp</dimen> + <dimen name="cell_switch_width">32dp</dimen> + <dimen name="cell_switch_height">52dp</dimen> + <dimen name="cell_switch_knob_margin">4dp</dimen> + <dimen name="cell_switch_knob_max_translation">20dp</dimen> + <dimen name="cell_switch_knob_size">24dp</dimen> </resources> |
