ドラッグ可能かつクリック可能なViewを作る

リサーチ・アンド・イノベーションの高田(tfandkusu)です。Androidエンジニアをやっています。昔から独自のUI部品やユニークなアニメーションを作ることにはこだわっていましたが、今回作ったドラッグ可能かつクリック可能なViewもなかなかの自信作なので紹介しようと思います。

FINE演出

CODEの買い物登録では、まずレシートを撮影して頂きますが、撮影されたレシート画像はOCR(光学的文字認識)処理され、日付、合計金額、店舗電話番号が読み取られます。CODEではサードパーティー製のレシートOCRライブラリを使用してOCR処理を行っています。クラウドの力は借りずにリソースの限られたスマホ端末内で完結して処理が行われますが、認識精度と処理速度の両面で高い完成度を持ったライブラリだと思います。とは言っても、レシートが遠すぎたり手ぶれしていたりピンボケしていたりすると読めません。

OCRが読めないレシート画像の例
OCRが読めないレシート画像の例

適切な撮影に誘導する仕組みとして、日付、合計金額、店舗電話番号のすべてが読み取れるとブタさんのキャラクターが褒めてくれる演出を作成しました。社内では「FINE演出」と呼ばれています。

FINE演出
FINE演出

クリックできる

FINE演出は左上のXボタンクリックで閉じることができます。閉じると小さい閉じたFINE演出になります。撮影状態やレシートの記載を確認できるようにするためです。そして閉じたFINE演出をクリックすると再び大きなFINE演出にできます。

クリックできる
クリックできる

ドラッグできる

しかし閉じたFINE演出でもレシートの表示に被ります。そこでドラッグで移動可能にしました。さらにそれによって読み取り結果が合っているかを該当箇所の隣にドラッグして確認することもできます。

ドラッグできる

ドラッグ可能かつクリック可能なViewの作り方

それではドラッグ可能かつクリック可能なViewの作り方を紹介します。それを実現できるライブラリが無いか探してみたのですが、見つけることができませんでした。なのでAndroid SDKの機能を組み合わせて実現しました。

まずレイアウトファイルはタッチイベントをキャッチする担当Viewと閉じたFINE演出に分けます。

<!-- 略 -->
        <FrameLayout
            android:id="@+id/fine_view_close_parent"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:layout_weight="1">

            <!-- 閉じたFINE演出(ドラッグ可能かつクリック可能) -->
            <include
                android:id="@+id/fine_view_close"
                layout="@layout/view_receipt_confirm_fine_close" />

            <!-- タッチイベントキャッチ担当(見えない) -->
            <View
                android:id="@+id/fine_close_touch_catcher"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:visibility="invisible" />
        </FrameLayout>
<!-- 略 -->

タッチイベントキャッチ担当と閉じたFINE演出
タッチイベントキャッチ担当と閉じたFINE演出

Android Studio
Android Studioでのプレビュー

下記のコードが閉じたFINE演出find_view_closeをクリック可能かつドラッグ可能にするコードです。ドラッグおよびクリックのイベントはすべてタッチイベントキャッチ担当fine_close_touch_catcherが担当します。取得したタッチイベントを処理した結果をfind_view_closeに反映させています。移動ならばViewの位置を基準の位置からずらすためのtranslationX、translationYプロパティを更新し、クリックならばperformClickメソッドを呼び出します。クリックおよびドラッグの判定はすべてAndroid SDK標準のGestureDetectorにお任せして、何ピクセル以上移動したらクリックでは無くてドラック操作になるといった複雑なタッチイベント判定処理は独自に書きません。また、タッチしたときのリップルエフェクトにもGestureDetectorのonShowPressコールバックメソッドを使って対応しています。

    /**
     * 閉じたFINE演出の移動
     */
    private fun setUpDragCloseView() {
        // View取得の方法はView Binding
        // 閉じたFINE演出高さ
        // 高さは可変長ではないのでここで取得。幅はこのタイミングだとテキスト適用前のデフォルトの幅が取得されてしまう
        val h = binding.fineViewClose.height
        // 可動範囲の大きさ
        val parentWidth = binding.fineCloseTouchCatcher.width
        val parentHeight = binding.fineCloseTouchCatcher.height
        // ドラッグで移動する
        val gestureDetector =
            GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
                override fun onDown(e: MotionEvent): Boolean {
                    // 閉じたFINE演出の上で起きたイベントか確認する
                    val x = binding.fineViewClose.x
                    val y = binding.fineViewClose.y
                    // 閉じたFINE演出幅
                    val w = binding.fineViewClose.width
                    // ここでtrueを返却すると、onScroll、onSingleTapUpイベントが呼ばれるようになる
                    return x <= e.x && y <= e.y && e.x <= x + w && e.y <= y + h
                }

                override fun onShowPress(e: MotionEvent) {
                    // タップ反応エフェクトを表示すべき時に呼ばれる
                    // 閉じたFINE演出の上で起きたイベントか確認する
                    val x = binding.fineViewClose.x
                    val y = binding.fineViewClose.y
                    // 閉じたFINE演出幅
                    val w = binding.fineViewClose.width
                    if (x <= e.x && y <= e.y && e.x <= x + w && e.y <= y + h) {
                        // リップルエフェクトの中心点を設定する
                        binding.fineViewClose.drawableHotspotChanged(
                            e.x - binding.fineViewClose.x,
                            e.y - binding.fineViewClose.y
                        )
                        // タップされたときのエフェクトを表示する
                        binding.fineViewClose.isPressed = true
                    }
                }

                override fun onScroll(
                    e1: MotionEvent,
                    e2: MotionEvent,
                    distanceX: Float,
                    distanceY: Float
                ): Boolean {
                    // タップされたときのエフェクトを解除して
                    binding.fineViewClose.isPressed = false
                    // 移動する
                    binding.fineViewClose.translationX -= distanceX
                    binding.fineViewClose.translationY -= distanceY
                    // はみ出さないようにする
                    // まずはみ出しピクセル数を計算
                    val x = binding.fineViewClose.x
                    val y = binding.fineViewClose.y
                    // 閉じたFINE演出幅
                    val w = binding.fineViewClose.width
                    val leftOver = 0 - x
                    val topOver = 0 - y
                    val rightOver = x + w - parentWidth
                    val bottomOver = y + h - parentHeight
                    // はみ出していたら戻す
                    if (leftOver > 0)
                        binding.fineViewClose.translationX += leftOver
                    if (topOver > 0)
                        binding.fineViewClose.translationY += topOver
                    if (rightOver > 0)
                        binding.fineViewClose.translationX -= rightOver
                    if (bottomOver > 0)
                        binding.fineViewClose.translationY -= bottomOver
                    return true
                }

                override fun onSingleTapUp(e: MotionEvent): Boolean {
                    // タップされたときのイベント処理
                    // タップされたときのエフェクトを解除して
                    binding.fineViewClose.isPressed = false
                    // 閉じたFINE演出をクリックする
                    binding.fineViewClose.performClick()
                    return true
                }
            })
        // ロングタップは無効化
        gestureDetector.setIsLongpressEnabled(false)
        // GestureDetectorにタッチイベントを送る
        binding.fineCloseTouchCatcher.setOnTouchListener { _, event ->
            gestureDetector.onTouchEvent(event)
        }
    }

最後に

前回の記事ではAndroidエンジニアを募集中と締めましたが、おかげさまで採用に成功しました。現在はエンジニア採用を行っていませんが、再び採用を開始することになりましたら、最近リニューアルされたコーポレートサイトや転職サイトに加えてこのブログでも紹介します。