Let's write β

プログラミング中にできたことか、思ったこととか

AndroidでスイッチUIの実装をいくつか比較検討してみた

つくりたかった物

f:id:Pocket7878_dev:20200326210053p:plain

デザイナーさんからZeplinでいただいていたイメージ これをどうやってつくるかプロトタイプを作成しながら比較検討しました。

onDrawでCanvasに書いていく

f:id:Pocket7878_dev:20200326210113p:plain

概要

AndroidのViewをカスタムする時にonDrawというメソッドの中で、 Canvasに独自で丸やテキストを書いたりする事でUIを再現する。

内部でホバーがどこに表示されているかを計算して、ジェスチャーに応じて描画に反映して 再描画する。

コード

package inc.azit.android.gesturelearning

import android.content.Context
import android.graphics.*
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.util.Log
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.View
import android.util.DisplayMetrics
import android.util.TypedValue

class CustomSwitcherView : View {

    private var ratio: Double = 0.0
    private val switchColor: Int
        get() = Color.argb(255, (255 * this.ratio).toInt(), 0, (255 * (1.0 - this.ratio)).toInt())
    private val hoverWidth: Int
        get() {
            return (width * 0.5).toInt()
        }
    private val contentWidth: Int
        get() {
            return width - paddingLeft - paddingRight
        }
    private val contentHeight: Int
        get() {
            return height - paddingTop - paddingBottom
        }
    private val hoverLeft: Int
        get() {
            return  (contentWidth / 2 * this.ratio).toInt()
        }

    private var draggingHover: Boolean = false

    lateinit var discontentDrawable: Drawable
    lateinit var goodDrawable: Drawable

    constructor(context: Context) : super(context) {
        init(context, null, 0)
    }

    constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
        init(context, attrs, 0)
    }

    constructor(context: Context, attrs: AttributeSet, defStyle: Int) : super(context, attrs, defStyle) {
        init(context, attrs, defStyle)

    }

    private fun init(context: Context, attrs: AttributeSet?, defStyle: Int) {

        this.discontentDrawable = context.resources.getDrawable(R.drawable.ic_discontent)
        this.goodDrawable = context.resources.getDrawable(R.drawable.ic_good)

        val gestureDetector = GestureDetector(context, object: GestureDetector.OnGestureListener {
            override fun onShowPress(p0: MotionEvent?) {
            }

            override fun onSingleTapUp(event: MotionEvent): Boolean {
                return false
            }

            override fun onDown(p0: MotionEvent): Boolean {
                onDownEvent(p0)
                return true
            }

            override fun onFling(p0: MotionEvent?, p1: MotionEvent?, p2: Float, p3: Float): Boolean {
                return false
            }

            override fun onScroll(scrollStart: MotionEvent, currentScroll: MotionEvent, distanceX: Float, distanceY: Float): Boolean {
                onScrollEvent(scrollStart, currentScroll, distanceX.toDouble())
                return true
            }

            override fun onLongPress(p0: MotionEvent?) {
            }
        })

        setOnTouchListener(object: View.OnTouchListener {
            override fun onTouch(p0: View, p1: MotionEvent): Boolean {
                if (p1.action == MotionEvent.ACTION_UP) {
                    onUp(p1)
                    return true
                } else {
                    return gestureDetector.onTouchEvent(p1)
                }
            }
        })
    }

    fun setRatio(newRatio: Double) {
        if (newRatio < 0.0 || newRatio > 1.0) {
            throw RuntimeException("Ratio out bounds: $newRatio")
        }
        this.ratio = newRatio
        invalidate()
    }

    private fun onDownEvent(event: MotionEvent) {
        this.draggingHover = inHoverArea(event.x.toInt())
    }

    private fun onUp(event: MotionEvent) {
        var newRatio: Double = (event.x.toDouble() / width.toDouble()).coerceIn(0.0, 1.0)
        if (newRatio < 0.5) {
            newRatio = 0.0
        } else {
            newRatio = 1.0
        }
        setRatio(newRatio)
        this.draggingHover = false
    }

    private fun onScrollEvent(scrollStartAt: MotionEvent, scrollingAt: MotionEvent, distanceX: Double) {
        if (draggingHover) {
            Log.d("switcher", "distanceX: $distanceX, width / 2: ${width.toDouble() / 2}")
            val ratioDiff: Double = (distanceX * -1) / (width.toDouble() / 2.0)
            Log.d("switcher", "Ratio diff: $ratioDiff")
            var newRatio: Double = (ratioDiff + ratio).coerceIn(0.0, 1.0)
            setRatio(newRatio)
        }
    }

    private fun inHoverArea(x: Int): Boolean {
        return hoverLeft <= x && x <= (hoverLeft + hoverWidth)
    }

    private fun dp2px(dp: Float): Int {
        val metrics = context.resources.displayMetrics
        return (dp * metrics.density).toInt()
    }

    private fun sp2px(sp: Float): Int {
        return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, sp, context.resources.displayMetrics).toInt()
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)

        val metrics = context.resources.displayMetrics

        //Draw Background
        val bgPaint = Paint()
        bgPaint.color = Color.WHITE
        canvas.drawCircle((height / 2).toFloat(), (height / 2).toFloat(), (height / 2).toFloat(), bgPaint)
        canvas.drawCircle((width - height / 2).toFloat(), (height / 2).toFloat(), (height / 2).toFloat(), bgPaint)
        canvas.drawRect(Rect((height / 2).toInt(), 0, width - (height / 2), height), bgPaint)

        /*
         * Draw Hover
         */
        val hoverPaint = Paint()
        hoverPaint.color = switchColor

        val hoverHeight: Int = (height * 0.8).toInt()
        val hoverTop: Int = (height * 0.1).toInt()
        val hoverWidth: Int = contentWidth / 2 - (height - hoverHeight) / 2
        val hoverLeft: Int = ((contentWidth / 2 - (height - hoverHeight) / 2) * this.ratio).toInt() + (height - hoverHeight) / 2
        val hoverRight: Int = hoverLeft + hoverWidth
        val hoverRadius: Int = hoverHeight / 2

        //Draw Hover Left Cap
        canvas.drawCircle(
            (hoverLeft + hoverHeight / 2).toFloat(),
            (height / 2.0).toFloat(),
            hoverRadius.toFloat(),
            hoverPaint)
        //Draw Hover Right Cap
        canvas.drawCircle(
            ((hoverLeft + hoverWidth) - hoverHeight / 2).toFloat(),
            (height / 2.0).toFloat(),
            hoverRadius.toFloat(),
            hoverPaint
        )
        //Draw Hover Body
        canvas.drawRect(
            Rect(
                (hoverLeft + hoverHeight / 2),
                hoverTop,
                hoverRight - hoverHeight / 2,
                hoverTop + hoverHeight
                ),
            hoverPaint
        )

        /*
         * Draw Left Icon
         */
        goodDrawable.setBounds(
            dp2px(20F),
            dp2px(12F),
            dp2px(20F + 24F),
            dp2px(12F + 24F))
        goodDrawable.draw(canvas)

        /*
         * Draw Left Text
         */
        val textPaint = Paint()
        textPaint.color = Color.WHITE
        textPaint.textSize = sp2px(14F).toFloat()
        canvas.drawText("良かった", dp2px(20F + 24F + 4F).toFloat(), dp2px(33F).toFloat(), textPaint)
    }
}

良い点

  • 単なるグラフィックの描画なので、 スムーズにジェスチャに追従する事ができる。
  • 色の変化等も直接描画しているために、コントロールしやすい。

悪い点

  • 円やテキストを独自で書く事になるため、レイアウトや座標系の管理を計算して実施する必要があり、画面のレイアウト構築が困難。
  • 影やグラーデーションといったリッチな表現にプラットフォームからの恩恵を受けづらい

Viewで構築してLayoutParamsをいじる

f:id:Pocket7878_dev:20200326210140p:plain

概要

Canvasで苦労した点をふまえて、逆にプラットフォームネイティブのViewを利用して Viewのレイアウトの数値を変更する事で、UIを再現する。

コンポーネントを、背景、ホバー、テキスト・アイコンという3層で構築して 背景とホバーでそれぞれジェスチャに応じて動くようにする。

全体をConstraintLayoutで配置して、ホバーの座標はConstraintLayoutのhorizontal biasを 調節する事によって移動させる。

参考記事

https://qiita.com/kikuchy/items/0fa6bd232930bb12e187#xml%E3%81%A7%E6%9B%B8%E3%81%84%E3%81%9Flayout%E3%82%92%E4%B8%80%E3%81%A4%E3%81%AEview%E3%81%A8%E3%81%97%E3%81%A6%E6%89%B1%E3%81%84%E3%81%9F%E3%81%84-2015912%E8%BF%BD%E8%A8%9820171122%E4%BF%AE%E6%AD%A3

コード

レイアウトファイル

<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
       xmlns:app="http://schemas.android.com/apk/res-auto"
       xmlns:tools="http://schemas.android.com/tools"
       tools:parentTag="android.widget.FrameLayout"
       android:layout_width="match_parent"
       android:layout_height="match_parent">

    <androidx.constraintlayout.widget.ConstraintLayout
            android:layout_width="match_parent"
                  android:layout_height="match_parent"
                  android:padding="4dp"
                  android:background="@drawable/custom_switcher_background"
                  android:orientation="horizontal">

        <View app:layout_constraintWidth_percent="0.5"
              android:layout_width="0dp"
              app:layout_constraintStart_toStartOf="parent"
              app:layout_constraintTop_toTopOf="parent"
              app:layout_constraintEnd_toEndOf="parent"
              app:layout_constraintBottom_toBottomOf="parent"
              android:layout_height="wrap_content"
              android:id="@+id/custom_switcher_hover"
              android:background="@drawable/custom_switcher_hover_background"
              app:layout_constraintHorizontal_bias="1.0"/>

        <androidx.constraintlayout.widget.ConstraintLayout
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toTopOf="parent"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintWidth_percent="0.5"
                app:layout_constraintHorizontal_bias="0.0"
                android:layout_width="0dp"
                android:layout_height="match_parent">

            <LinearLayout
                    app:layout_constraintStart_toStartOf="parent"
                    app:layout_constraintTop_toTopOf="parent"
                    app:layout_constraintEnd_toEndOf="parent"
                    app:layout_constraintBottom_toBottomOf="parent"
                    android:orientation="horizontal"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content">

                <ImageView
                        android:layout_width="24dp"
                        android:layout_height="24dp"
                        app:srcCompat="@drawable/ic_good"
                        android:id="@+id/left_icon_image_view"
                        app:layout_constraintTop_toTopOf="parent"
                        app:layout_constraintBottom_toBottomOf="parent"
                        app:layout_constraintStart_toStartOf="parent"/>

                <Space android:layout_width="4dp" android:layout_height="0dp"/>

                <TextView
                        android:text="良かった"
                        android:layout_width="wrap_content"
                        android:layout_height="wrap_content"
                        android:textSize="14sp"
                        android:id="@+id/left_text_view"
                        android:textStyle="bold"
                        android:textColor="@android:color/white"
                        app:layout_constraintStart_toEndOf="@id/left_icon_image_view"
                        app:layout_constraintEnd_toEndOf="parent"
                        app:layout_constraintTop_toTopOf="parent"
                        app:layout_constraintBottom_toBottomOf="parent" android:layout_gravity="center_vertical"/>
            </LinearLayout>

        </androidx.constraintlayout.widget.ConstraintLayout>

        <androidx.constraintlayout.widget.ConstraintLayout
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toTopOf="parent"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintWidth_percent="0.5"
                app:layout_constraintHorizontal_bias="1.0"
                android:layout_width="0dp"
                android:layout_height="match_parent">

            <LinearLayout
                    app:layout_constraintStart_toStartOf="parent"
                    app:layout_constraintTop_toTopOf="parent"
                    app:layout_constraintEnd_toEndOf="parent"
                    app:layout_constraintBottom_toBottomOf="parent"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content">

                <ImageView
                        android:layout_width="24dp"
                        android:layout_height="24dp"
                        app:srcCompat="@drawable/ic_discontent"
                        android:id="@+id/right_icon_image_view"
                        app:layout_constraintTop_toTopOf="parent"
                        app:layout_constraintBottom_toBottomOf="parent"
                        app:layout_constraintStart_toStartOf="parent"/>

                <Space android:layout_width="4dp" android:layout_height="0dp"/>

                <TextView
                        android:text="不満あり"
                        android:layout_width="wrap_content"
                        android:layout_height="wrap_content"
                        android:textSize="14sp"
                        android:id="@+id/right_text_view"
                        android:textStyle="bold"
                        android:textColor="@android:color/white"
                        app:layout_constraintStart_toEndOf="@id/right_icon_image_view"
                        app:layout_constraintEnd_toEndOf="parent"
                        app:layout_constraintTop_toTopOf="parent"
                        app:layout_constraintBottom_toBottomOf="parent" android:layout_gravity="center_vertical"/>
            </LinearLayout>
        </androidx.constraintlayout.widget.ConstraintLayout>

    </androidx.constraintlayout.widget.ConstraintLayout>
</merge>

クラス

package inc.azit.android.gesturelearning

import android.content.Context
import android.util.AttributeSet
import android.util.Log
import android.view.GestureDetector
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.Space
import androidx.constraintlayout.widget.ConstraintLayout
import java.util.logging.Handler
import java.util.zip.DeflaterOutputStream
import kotlin.math.absoluteValue

class CustomSwitcherByViewView : FrameLayout {

    lateinit var racketView: View

    private var ratio: Double = 0.0

    constructor(context: Context) : super(context) {
        init(context, null, 0)
    }

    constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
        init(context, attrs, 0)
    }

    constructor(context: Context, attrs: AttributeSet, defStyle: Int) : super(context, attrs, defStyle) {
        init(context, attrs, defStyle)
    }

    private fun init(context: Context, attrs: AttributeSet?, defStyle: Int) {
        val rootView: View = LayoutInflater.from(context).inflate(R.layout.custom_switcher_by_view_layout, this, true)

        rootView.setOnTouchListener(object : OnTouchListener {
            override fun onTouch(p0: View, p1: MotionEvent): Boolean {
                if (p1.action == MotionEvent.ACTION_UP) {
                    onRootGestureUpEvent(p1)
                }
                return true
            }
        })

        racketView = rootView.findViewById(R.id.custom_switcher_hover)

        val racketGestureDetector = buildRacketGestureDetector(context)
        racketView.setOnTouchListener(object: OnTouchListener {
            override fun onTouch(v: View, event: MotionEvent): Boolean {
                if (event.action == MotionEvent.ACTION_UP) {
                    onRacketUpEvent(event)
                    return true
                } else {
                    return racketGestureDetector.onTouchEvent(event)
                }
            }
        })
    }

    fun setRatio(newRatio: Double) {
        if (newRatio < 0.0 || newRatio > 1.0) {
            throw RuntimeException("Ratio out bounds: $newRatio")
        }

        val racketLayoutParams = racketView.layoutParams as ConstraintLayout.LayoutParams
        racketLayoutParams.horizontalBias = newRatio.toFloat()
        racketView.layoutParams = racketLayoutParams

        this.ratio = newRatio
    }

    /*
     * Root View Gesture
     */
    private fun onRootGestureUpEvent(event: MotionEvent) {
        var newRatio: Double = (event.x.toDouble() / width).coerceIn(0.0, 1.0)
        if (newRatio < 0.5) {
            newRatio = 0.0
        } else {
            newRatio = 1.0
        }
        setRatio(newRatio)
    }

    /*
     * Racket Gesture Detector
     */
    private fun buildRacketGestureDetector(context: Context): GestureDetector {
        return GestureDetector(context, object: GestureDetector.OnGestureListener {
            override fun onShowPress(p0: MotionEvent?) {
            }

            override fun onSingleTapUp(p0: MotionEvent?): Boolean {
                return false
            }

            override fun onDown(event: MotionEvent): Boolean {
                return true
            }

            override fun onFling(p0: MotionEvent?, p1: MotionEvent?, p2: Float, p3: Float): Boolean {
                return false
            }

            override fun onScroll(scrollStart: MotionEvent, current: MotionEvent, distanceX: Float, distanceY: Float): Boolean {
                val ratioDiff: Double = (distanceX.toDouble() * -1) / (width.toDouble() / 2.0)
                var newRatio: Double = (ratioDiff + ratio).coerceIn(0.0, 1.0)
                Log.d("switch", "New Ratio from onScroll: $newRatio")
                setRatio(newRatio)
                return true
            }

            override fun onLongPress(p0: MotionEvent?) {
            }
        })
    }

    private fun onRacketUpEvent(event: MotionEvent) {
        var newRatio: Double = ratio
        if (newRatio < 0.5) {
            newRatio = 0.0
        } else {
            newRatio = 1.0
        }
        setRatio(newRatio)
    }
}

良い点

  • Androidの標準のViewコンポーネントやレイアウトルールがつかえる分、UIの再現がしやすい。
  • ConstraintLayoutを利用しているため、画面サイズに対応して適切にレイアウトが調節されるという安心感を持ちやすい

悪い点

  • パフォーマンスがわるい、おそらくonScrollの中でレイアウトを変更しているが、レイアウトの変更処理が重いため
  • onScroll内の処理が重い結果、描画が安定しない、指への追従性が悪い
  • レイアウトの縮小化等を試みたが、状況が大幅に改善する事はなかった

Lottieをつかって描画する

f:id:Pocket7878_dev:20200326210209p:plain

手法

デザイナーさんからLottieのファイルをもらって、アニメーションの進捗パラメータを変更する事でホバーの移動を表現する。 ホバーの位置については、想定される位置を計算する事で、適切に反応しているように見えるよう調節する

コード

package inc.azit.android.gesturelearning

import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.drawable.Drawable
import android.text.TextPaint
import android.util.AttributeSet
import android.util.Log
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.View
import com.airbnb.lottie.LottieAnimationView
import android.animation.ValueAnimator



/**
 * TODO: document your custom view class.
 */
class CustomSwitcherByLottieView : LottieAnimationView {

    private var ratio: Double = 0.0
    private var draggingHover: Boolean = false

    constructor(context: Context) : super(context) {
        init(context, null, 0)
    }

    constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
        init(context, attrs, 0)
    }

    constructor(context: Context, attrs: AttributeSet, defStyle: Int) : super(context, attrs, defStyle) {
        init(context, attrs, defStyle)
    }

    private fun setRatio(newRatio: Double, animate: Boolean = false) {
        if (newRatio < 0.0 || newRatio > 1.0) {
            throw RuntimeException("Ratio out bounds: $newRatio")
        }
        if (animate) {
            val animator = ValueAnimator.ofFloat(ratio.toFloat(), newRatio.toFloat())
            animator.duration = 300
            animator.addUpdateListener { animation -> progress = animation.animatedValue as Float }
            animator.start()

        } else {
            this.progress = newRatio.toFloat()
        }
        this.ratio = newRatio
    }

    private fun init(context: Context, attrs: AttributeSet?, defStyle: Int) {
        val gestureDetector = GestureDetector(context, object: GestureDetector.OnGestureListener {
            override fun onShowPress(p0: MotionEvent?) {
            }

            override fun onSingleTapUp(p0: MotionEvent?): Boolean {
                return false
            }

            override fun onDown(p0: MotionEvent): Boolean {
                draggingHover = inHover(p0)
                return true
            }

            override fun onFling(p0: MotionEvent?, p1: MotionEvent?, p2: Float, p3: Float): Boolean {
                return false
            }

            override fun onScroll(scrollStart: MotionEvent, current: MotionEvent, diffX: Float, diffY: Float): Boolean {
                if (draggingHover) {
                    val ratioDiff: Double = (diffX.toDouble() * -1) / (width.toDouble() / 2.0)
                    var newRatio: Double = (ratioDiff + ratio).coerceIn(0.0, 1.0)
                    Log.d("switch", "New Ratio from onScroll: $newRatio")
                    setRatio(newRatio)
                }
                return true
            }


            override fun onLongPress(p0: MotionEvent?) {
            }
        })

        setOnTouchListener { view, motionEvent ->
            if (motionEvent.action == MotionEvent.ACTION_UP) {
                onMotionUp(motionEvent)
                draggingHover = false
                true
            } else {
                gestureDetector.onTouchEvent(motionEvent)
            }
        }
    }

    private fun inHover(event: MotionEvent): Boolean {
        val positon = event.x
        val hoverLeft = width * 0.5 * this.ratio
        val hoverRight = hoverLeft + width * 0.5
        return positon in hoverLeft..hoverRight
    }

    private fun onMotionUp(event: MotionEvent) {
        var newRatio: Double = (event.x.toDouble() / width.toDouble()).coerceIn(0.0, 1.0)
        if (newRatio < 0.5) {
            newRatio = 0.0
        } else {
            newRatio = 1.0
        }
        setRatio(newRatio, true)
    }
}

良い点

  • デザイナーさんから貰ったデザインがほぼそのまま動くので、デザインを再現するというステップを挟まなくて良い。
  • スクロール時のパフォーマンスも非常に良く、Canvasと同程度でサクサク動く

悪い点

  • Canvas手法やView手法と異なり、ホバーが表示されている範囲をプログラム内で正確に管理するのが困難
  • アニメーションの構成にも依るが、角丸が表示されておらずLottieファイルの調節が必要
  • Lottieがサポートしていないような属性を使っているグラフィクスは再現するのが困難になる

CanvasとLayoutのハイブリッド

f:id:Pocket7878_dev:20200326210234p:plain

手法

Canvasで描画した場合に、ホバーについてはシンプルな形状であるため描画のコストはそれほどでないが、 テキストやアイコンのレイアウト等については管理が複雑になってしまうという課題があった。 そこで、ホバーはCanvasで書き、その上に重ねてテキストやアイコンはViewのLayoutで描画するというハイブリッド手法

コード

レイアウトファイル

<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
       xmlns:tools="http://schemas.android.com/tools" xmlns:app="http://schemas.android.com/apk/res-auto"
       tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout"
       android:layout_width="match_parent"
       android:layout_height="match_parent">

    <inc.azit.android.gesturelearning.hybrid.CustomSwitcherHoverCanvasView
            android:id="@+id/hybrid_hover_canvas"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"
            android:layout_width="0dp"
            android:layout_height="0dp"
    />

    <androidx.constraintlayout.widget.ConstraintLayout
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintWidth_percent="0.5"
            app:layout_constraintHorizontal_bias="0.0"
            android:layout_width="0dp"
            android:layout_height="match_parent">

        <LinearLayout
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toTopOf="parent"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintBottom_toBottomOf="parent"
                android:orientation="horizontal"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content">

            <ImageView
                    android:layout_width="24dp"
                    android:layout_height="24dp"
                    app:srcCompat="@drawable/ic_good"
                    android:id="@+id/left_icon_image_view"
                    app:layout_constraintTop_toTopOf="parent"
                    app:layout_constraintBottom_toBottomOf="parent"
                    app:layout_constraintStart_toStartOf="parent"/>

            <Space android:layout_width="4dp" android:layout_height="0dp"/>

            <TextView
                    android:text="良かった"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:textSize="14sp"
                    android:id="@+id/left_text_view"
                    android:textStyle="bold"
                    android:textColor="@android:color/white"
                    app:layout_constraintStart_toEndOf="@id/left_icon_image_view"
                    app:layout_constraintEnd_toEndOf="parent"
                    app:layout_constraintTop_toTopOf="parent"
                    app:layout_constraintBottom_toBottomOf="parent" android:layout_gravity="center_vertical"/>
        </LinearLayout>

    </androidx.constraintlayout.widget.ConstraintLayout>

    <androidx.constraintlayout.widget.ConstraintLayout
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintWidth_percent="0.5"
            app:layout_constraintHorizontal_bias="1.0"
            android:layout_width="0dp"
            android:layout_height="match_parent">

        <LinearLayout
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toTopOf="parent"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintBottom_toBottomOf="parent"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content">

            <ImageView
                    android:layout_width="24dp"
                    android:layout_height="24dp"
                    app:srcCompat="@drawable/ic_discontent"
                    android:id="@+id/right_icon_image_view"
                    app:layout_constraintTop_toTopOf="parent"
                    app:layout_constraintBottom_toBottomOf="parent"
                    app:layout_constraintStart_toStartOf="parent"/>

            <Space android:layout_width="4dp" android:layout_height="0dp"/>

            <TextView
                    android:text="不満あり"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:textSize="14sp"
                    android:id="@+id/right_text_view"
                    android:textStyle="bold"
                    android:textColor="@android:color/white"
                    app:layout_constraintStart_toEndOf="@id/right_icon_image_view"
                    app:layout_constraintEnd_toEndOf="parent"
                    app:layout_constraintTop_toTopOf="parent"
                    app:layout_constraintBottom_toBottomOf="parent" android:layout_gravity="center_vertical"/>
        </LinearLayout>
    </androidx.constraintlayout.widget.ConstraintLayout>
</merge>

Canvas Hover View

package inc.azit.android.gesturelearning.hybrid

import android.animation.ValueAnimator
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Rect
import android.util.AttributeSet
import android.util.Log
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.View

class CustomSwitcherHoverCanvasView: View {

    interface OnRatioChangeListener {
        fun onRatioChanged(ratio: Double)
    }


    var ratioChangeListener: OnRatioChangeListener? = null

    private var ratio: Double = 0.0
    private val switchColor: Int
        get() = Color.argb(255, (255 * this.ratio).toInt(), 0, (255 * (1.0 - this.ratio)).toInt())
    private val hoverWidth: Int
        get() {
            return (width * 0.5).toInt()
        }
    private val contentWidth: Int
        get() {
            return width - paddingLeft - paddingRight
        }
    private val contentHeight: Int
        get() {
            return height - paddingTop - paddingBottom
        }
    private val hoverLeft: Int
        get() {
            return  (contentWidth / 2 * this.ratio).toInt()
        }

    private var draggingHover: Boolean = false

    constructor(context: Context) : super(context) {
        init(context, null, 0)
    }

    constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
        init(context, attrs, 0)
    }

    constructor(context: Context, attrs: AttributeSet, defStyle: Int) : super(context, attrs, defStyle) {
        init(context, attrs, defStyle)

    }

    private fun init(context: Context, attrs: AttributeSet?, defStyle: Int) {

        val gestureDetector = GestureDetector(context, object: GestureDetector.OnGestureListener {
            override fun onShowPress(p0: MotionEvent?) {
            }

            override fun onSingleTapUp(event: MotionEvent): Boolean {
                return false
            }

            override fun onDown(p0: MotionEvent): Boolean {
                onDownEvent(p0)
                return true
            }

            override fun onFling(p0: MotionEvent?, p1: MotionEvent?, p2: Float, p3: Float): Boolean {
                return false
            }

            override fun onScroll(scrollStart: MotionEvent, currentScroll: MotionEvent, distanceX: Float, distanceY: Float): Boolean {
                onScrollEvent(scrollStart, currentScroll, distanceX.toDouble())
                return true
            }

            override fun onLongPress(p0: MotionEvent?) {
            }
        })

        setOnTouchListener(object: View.OnTouchListener {
            override fun onTouch(p0: View, p1: MotionEvent): Boolean {
                if (p1.action == MotionEvent.ACTION_UP) {
                    onUp(p1)
                    return true
                } else {
                    return gestureDetector.onTouchEvent(p1)
                }
            }
        })
    }

    fun setRatio(newRatio: Double, animate: Boolean = false) {
        if (newRatio < 0.0 || newRatio > 1.0) {
            throw RuntimeException("Ratio out bounds: $newRatio")
        }

        if (animate) {
            val animator = ValueAnimator.ofFloat(ratio.toFloat(), newRatio.toFloat())
            animator.duration = 300
            animator.addUpdateListener {
                    animation ->
                val currentValue = animation.animatedValue as Float
                this.ratio = currentValue.toDouble()
                ratioChangeListener?.onRatioChanged(currentValue.toDouble())
                invalidate()
            }
            animator.start()
        } else {
            this.ratio = newRatio
            ratioChangeListener?.onRatioChanged(newRatio)
            invalidate()
        }
    }

    private fun onDownEvent(event: MotionEvent) {
        this.draggingHover = inHoverArea(event.x.toInt())
    }

    private fun onUp(event: MotionEvent) {
        var newRatio: Double = (event.x.toDouble() / width.toDouble()).coerceIn(0.0, 1.0)
        if (newRatio < 0.5) {
            newRatio = 0.0
        } else {
            newRatio = 1.0
        }
        setRatio(newRatio, true)
        this.draggingHover = false
    }

    private fun onScrollEvent(scrollStartAt: MotionEvent, scrollingAt: MotionEvent, distanceX: Double) {
        if (draggingHover) {
            Log.d("switcher", "distanceX: $distanceX, width / 2: ${width.toDouble() / 2}")
            val ratioDiff: Double = (distanceX * -1) / (width.toDouble() / 2.0)
            Log.d("switcher", "Ratio diff: $ratioDiff")
            var newRatio: Double = (ratioDiff + ratio).coerceIn(0.0, 1.0)
            setRatio(newRatio, false)
        }
    }

    private fun inHoverArea(x: Int): Boolean {
        return hoverLeft <= x && x <= (hoverLeft + hoverWidth)
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)

        //Draw Background
        val bgPaint = Paint()
        bgPaint.color = Color.WHITE
        canvas.drawCircle((height / 2).toFloat(), (height / 2).toFloat(), (height / 2).toFloat(), bgPaint)
        canvas.drawCircle((width - height / 2).toFloat(), (height / 2).toFloat(), (height / 2).toFloat(), bgPaint)
        canvas.drawRect(Rect((height / 2).toInt(), 0, width - (height / 2), height), bgPaint)

        /*
         * Draw Hover
         */
        val hoverPaint = Paint()
        hoverPaint.color = switchColor

        val hoverHeight: Int = (height * 0.8).toInt()
        val hoverTop: Int = (height * 0.1).toInt()
        val hoverWidth: Int = contentWidth / 2 - (height - hoverHeight) / 2
        val hoverLeft: Int = ((contentWidth / 2 - (height - hoverHeight) / 2) * this.ratio).toInt() + (height - hoverHeight) / 2
        val hoverRight: Int = hoverLeft + hoverWidth
        val hoverRadius: Int = hoverHeight / 2

        //Draw Hover Left Cap
        canvas.drawCircle(
            (hoverLeft + hoverHeight / 2).toFloat(),
            (height / 2.0).toFloat(),
            hoverRadius.toFloat(),
            hoverPaint)
        //Draw Hover Right Cap
        canvas.drawCircle(
            ((hoverLeft + hoverWidth) - hoverHeight / 2).toFloat(),
            (height / 2.0).toFloat(),
            hoverRadius.toFloat(),
            hoverPaint
        )
        //Draw Hover Body
        canvas.drawRect(
            Rect(
                (hoverLeft + hoverHeight / 2),
                hoverTop,
                hoverRight - hoverHeight / 2,
                hoverTop + hoverHeight
            ),
            hoverPaint
        )
    }
}

HybridView

package inc.azit.android.gesturelearning.hybrid

import android.content.Context
import android.content.res.ColorStateList
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.drawable.Drawable
import android.text.TextPaint
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.widget.ImageViewCompat
import inc.azit.android.gesturelearning.R

/**
 * TODO: document your custom view class.
 */
class CustomSwitcherByCanvasLayoutHybridView : ConstraintLayout {

    lateinit var leftIconView: ImageView
    lateinit var leftTextView: TextView

    lateinit var rightIconView: ImageView
    lateinit var rightTextView: TextView


    constructor(context: Context) : super(context) {
        init(context, null, 0)
    }

    constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
        init(context, attrs, 0)
    }

    constructor(context: Context, attrs: AttributeSet, defStyle: Int) : super(context, attrs, defStyle) {
        init(context, attrs, defStyle)
    }

    private fun init(context: Context, attrs: AttributeSet?, defStyle: Int) {
        val rootView: View = LayoutInflater.from(context).inflate(R.layout.custom_switcher_by_canvas_layout_hibrid_layout, this, true)
        leftIconView = rootView.findViewById(R.id.left_icon_image_view)
        leftTextView = rootView.findViewById(R.id.left_text_view)

        rightIconView = rootView.findViewById(R.id.right_icon_image_view)
        rightTextView = rootView.findViewById(R.id.right_text_view)
        rootView.findViewById<CustomSwitcherHoverCanvasView>(R.id.hybrid_hover_canvas).ratioChangeListener = object: CustomSwitcherHoverCanvasView.OnRatioChangeListener {
            override fun onRatioChanged(ratio: Double) {
                val leftTint = leftItemTintColor(ratio)
                val rightTint = rightItemTintColor(ratio)

                ImageViewCompat.setImageTintList(leftIconView, ColorStateList.valueOf(leftTint))
                leftTextView.setTextColor(leftTint)

                ImageViewCompat.setImageTintList(rightIconView, ColorStateList.valueOf(rightTint))
                rightTextView.setTextColor(rightTint)
            }
        }
    }

    private fun leftItemTintColor(ratio: Double): Int {
        return Color.rgb((255 * (1.0 - ratio)).toInt(), (255 * (1.0 - ratio)).toInt(), (255 * (1.0 - ratio)).toInt())
    }

    private fun rightItemTintColor(ratio: Double): Int {
        return Color.rgb((255 * ratio).toInt(), (255 * ratio).toInt(), (255 * ratio).toInt())
    }
}

良い点

  • 描画のパフォーマンスが落ちない、ホバーのスムーズさについてはCanvasと(ほぼ?)同様になっている。
  • キャンバスで描画する領域が限られているので、デザインの装飾がやりやすい

悪い点

  • 実装工数が多い(これについては再利用性をうまくつくればいけるか?)
  • ホバーにたいするリッチな表現自体はCanvasと同様に自前で描画する必要がある

最終的に

総合的に最後のハイブリッドパターンがコスパのバランスが良いという事で最終的な実装に利用しました。

つくりたかった物 できた物
f:id:Pocket7878_dev:20200326210053p:plain f:id:Pocket7878_dev:20200326210356g:plain

こんな感じで比較的綺麗に良い感じで再現できたかとおもいます。 ホバーの影等もキャンバスなので調整可能ですが、スキップという感じにデザイナーさんと話して無事リリースできました。

ライブラリとしてリリースしました

pocket7878/SwitcherView

f:id:Pocket7878_dev:20200326210440p:plain

<jp.pocket7878.switcherview.SwitcherView
    app:sv_background_color="@color/white"
    app:sv_leftmost_hover_color="@color/primary"
    app:sv_rightmost_hover_color="@color/alert"
    app:sv_left_choice_icon_src="@drawable/ic_good"
    app:sv_left_choice_text="@string/switcher_good_text"
    app:sv_right_choice_icon_src="@drawable/ic_discontent"
    app:sv_right_choice_text="@string/switcher_discontent_text"
    app:sv_disable_choice_tint_color="@color/icon_default"
    app:sv_enable_choice_tint_color="@color/white"
    />

こんな感じで指定して利用する事ができます。