つくりたかった物
デザイナーさんからZeplinでいただいていたイメージ
これをどうやってつくるかプロトタイプを作成しながら比較検討しました。
onDrawでCanvasに書いていく
概要
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をいじる
概要
Canvasで苦労した点をふまえて、逆にプラットフォームネイティブのViewを利用して
Viewのレイアウトの数値を変更する事で、UIを再現する。
コンポーネントを、背景、ホバー、テキスト・アイコンという3層で構築して
背景とホバーでそれぞれジェスチャに応じて動くようにする。
全体をConstraintLayoutで配置して、ホバーの座標はConstraintLayoutのhorizontal biasを
調節する事によって移動させる。
参考記事
コード
レイアウトファイル
<?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をつかって描画する
手法
デザイナーさんから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のハイブリッド
手法
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と同様に自前で描画する必要がある
最終的に
総合的に最後のハイブリッドパターンがコスパのバランスが良いという事で最終的な実装に利用しました。
つくりたかった物 | できた物 |
---|---|
こんな感じで比較的綺麗に良い感じで再現できたかとおもいます。
ホバーの影等もキャンバスなので調整可能ですが、スキップという感じにデザイナーさんと話して無事リリースできました。
ライブラリとしてリリースしました
<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"
/>
こんな感じで指定して利用する事ができます。