LoginSignup
15
20

More than 5 years have passed since last update.

Android でスタンプ機能を実現する 【Kotlin】

Posted at

自己紹介

こんにちわ
株式会社アクトインディ アプリチームのspelunker_muzです。
弊社のアプリ「いこーよ」はKotlin開発しています。

Androidでスタンプ機能を実現するため実装内容をまとめました。

やりたいこと

写真を撮影またはギャラリーから写真をピックアップしてスタンプを押したい。
写真に顔が写った際、顔だけ隠したい。
こんな感じです。

s1.png

仕様について

スタンプを押したい画像に対し画面下部のRecyclerViewのスタンプピッカーからスタンプを選択。

s2.png

画像の上にスタンプを配置。配置したViewを画像化して保存。
スタンプは拡大・縮小・移動が可能。

スタンプをタップしアクティブ時には四隅ボーダーを付け
非アクティブ時にボーダーを非表示にする。

操作手順

:one: 写真を撮影
:two: 撮影した写真をViewに配置
:three: スタンプを押す
:four: ViewをBitmapへ変換する

早速説明に入ります。

:one: 写真を撮影

Extensionで以下のコードを追加。

extensions.kt
fun Activity.startActivityForResultCamera(cameraUri: Uri) {
    val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
    intent.putExtra(MediaStore.EXTRA_OUTPUT, cameraUri)
    this.startActivityForResult(intent, RequestCode.Camera.ordinal)
}

今回は最低限の実装を以下にて書きました。

PhotoEditActivity.kt

private var cameraUri: Uri? = null

enum class RequestCode {
    Camera
}

//uriを取得
fun getCameraUri(context: Context): Uri {
    val imageName = "${System.currentTimeMillis()}.jpg"
    val contentValues = ContentValues()
    contentValues.put(MediaStore.Images.Media.TITLE, imageName)
    contentValues.put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
    return context.contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
}

//カメラを起動する
fun execCamera() {
    this.cameraUri = getCameraUri(context = this)
    cameraUri?.let {
        startActivityForResultCamera(cameraUri = it)
    }
}

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        when (requestCode) {
            RequestCode.Camera.ordinal
            -> {
                if (resultCode != Activity.RESULT_OK) return
                val uri = this.cameraUri ?: return  //<-このuriで撮影した画像がとれる
            }
    }
}

android6以降はカメラ起動前に「ストレージ」Permissionを取得しないと落ちます。
とりあえず今回は動けばいいのでbuild後 設定 -> アプリ から手動でPermissionの「ストレージ」を許可
PermissionDispatcherなどを使用して適宜実装。
https://github.com/permissions-dispatcher/PermissionsDispatcher

:two: 撮影した写真をViewに配置

:one: で取得したuriでViewへ配置
こちらもExtensionなどでまとまりを予め用意しておきglideなどの画像ライブラリで配置すると楽です。
https://github.com/bumptech/glide

fun ImageView.glideWithPlaceholder(context: Context, urlString: String) {
    Glide.with(context)
            .load(urlString)
            .placeholder(R.drawable.img_loading)
            .error(R.drawable.img_error)
            .dontAnimate()
            .fitCenter()
            .into(this)
}

使い方

imageView.glideWithPlaceholder(context = this, uri = uri) //表示させたいimageViewに対してカメラで取得したuriを渡す。

:three: スタンプを押す

さてここからが本題です。
:one:,:two:の手順で写真撮影して画像を配置する。ここまではなんの問題なく実装をしてきました。
ここから実際のスタンプ処理に触れたいと思います。

スタンプ押印画面はざっくり
画面上部はスタンプを押したい画像を配置。
その上にスタンプをコントロールするためのボタンUIを配置(削除・拡大・縮小)
画面下部分はスタンプをピックアップする部分、よくLINEなんかであるスタンプピッカー。

スタンプ押印にはスタンプピッカーをタップで選び配置する。
具体的には画面下部にRecyclerViewでスタンプピッカーを作成し選択できるようにする。

ここではframeLayoutImageViewEditStage(任意命名)というId名でViewを作成しておき
スタンプを押す画像、スタンプを配置していきます。
s3.png

Viewのレイヤー構造は
:one: スタンプのコントローラー(削除・拡大・縮小ボタン)
:two: スタンプ
:three: スタンプを押したい画像

スタンプのピッカーを作る

今回はシンプルなRecyclerViewで作ります。(最低限実装)
これです。
s2.png

PhotoEditActivity.kt
interface PhotoEditActivityInterface {
    fun setStampFromAdapter(position: Int)
}

class PhotoEditActivity : AppCompatActivity(),PhotoEditActivityInterface {
    override fun setStampFromAdapter(position: Int) {
        //ピッカーを選択したときの処理
        frameLayoutImageViewEditStage.addView(createStamp(position = position))
    }
    private fun setRecyclerView(list: MutableList<StampPickerModel>) {
        val GRID_QUANTITY = 4
        recyclerView?.let {
            it.layoutManager = GridLayoutManager(this,GRID_QUANTITY)
            recyclerViewAdapter = StampPickerAdapter(this, list)
            it.adapter = recyclerViewAdapter
        }
    }
}
RecyclerViewのAdapter
class StampPickerAdapter(private val context: Context, private val data: MutableList<StampPickerModel>) : RecyclerView.Adapter<StampPickerAdapter.ViewHolder>(), View.OnClickListener {
    var recycler: RecyclerView? = null
    var inflater: LayoutInflater? = null
    init {
        inflater = LayoutInflater.from(context)
    }

    override fun onAttachedToRecyclerView(recyclerView: RecyclerView?) {
        super.onAttachedToRecyclerView(recyclerView)
        recycler = recyclerView
    }

    override fun onClick(v: View?) {
        recycler?.let {
            if (context is PhotoEditActivityInterface) {
                //スタンプを選択して配置する処理
                context.setStampFromAdapter(position = it.getChildAdapterPosition(v))
            }
        }
    }

    override fun onBindViewHolder(holder: ViewHolder?, position: Int) {
        holder?.let {
            Glide.with(context)
                    .load(data[position].drawable)
                    .error(R.drawable.img_error)
                    .dontAnimate()
                    .fitCenter()
                    .into(it.imageViews)
        }
    }

    override fun onCreateViewHolder(viewGroup: ViewGroup, i: Int): ViewHolder? {
        val view = inflater?.inflate(R.layout.adapter_picker_stamp_list, viewGroup, false)
        view?.setOnClickListener(this)
        val holder = ViewHolder(view)
        return holder
    }

    override fun getItemCount(): Int {
        data.let {
            return it.size
        }
    }

    open inner class ViewHolder(imageView: View?) : RecyclerView.ViewHolder(imageView) {
        var imageViews: ImageView = itemView.findViewById(R.id.imageViewThumbnail) as ImageView
    }
}

今回はColorなどコピペしやすいようベタ書きしてしまいます:bow:

スタンプを表示するRecyclerView
<?xml version="1.0" encoding="utf-8"?>
<android.support.v7.widget.RecyclerView
    android:id="@+id/recyclerView"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:layout_weight="3"
    android:background="#fff"
    app:layout_constraintHorizontal_bias="0.0"
    app:layout_constraintLeft_toLeftOf="parent"
    app:layout_constraintRight_toRightOf="parent"
    app:layout_constraintTop_toBottomOf="@+id/frameLayoutImageViewEditStage" />
RecyclerViewのAdapter
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="horizontal">

    <RelativeLayout
        android:layout_width="match_parent"
        android:layout_height="70dp"
        tools:ignore="UselessParent">

        <ImageView
            android:id="@+id/imageViewThumbnail"
            android:layout_width="60dp"
            android:layout_height="65dp"
            android:layout_margin="8dp"
            android:padding="3dp"
            android:scaleType="centerCrop"
            tools:ignore="ContentDescription" />

    </RelativeLayout>

</LinearLayout>
スタンプのデータ
data class StampPickerModel(var drawable: Int,var id: String = "") : Serializable
class StampModel {
    fun createStampPickerListModel() : MutableList<StampPickerModel> {
        val stampPickerModel = emptyArray<StampPickerModel>().toMutableList()
        stampPickerModel.add(StampPickerModel(drawable = R.drawable.stamp01,id = UUID.randomUUID().toString()))
        stampPickerModel.add(StampPickerModel(drawable = R.drawable.stamp02,id = UUID.randomUUID().toString()))
        return stampPickerModel
    }
}

実際に呼び出すコード

setRecyclerView(list = StampModel().createStampPickerListModel())

:point_up: スタンプを配置する

スタンプピッカーのRecyclerViewをタップしたときに以下の処理を呼び出します。
予めスタンプのレイアウトファイルを作っておきLayoutInflaterで呼び出す。
呼び出したスタンプに初期の大きさや位置ドラッグしたときのListenerをセットする。

tagはスタンプの管理に使います。
スタンプをタップしたときにアクティブにしたり
スタンプを削除したり。

fun createStamp(position: Int) : View? {
    //予め作っておいたViewをinflaterで呼び出す
    val inflater = LayoutInflater.from(this)
    val stampView = inflater.inflate(R.layout.view_stamp,frameLayoutImageViewEditStage,false)
    val imageViewStamp = stampView.findViewById(R.id.imageViewStamp) as ImageView
    val stampViewGroup = stampView as ViewGroup? ?: return stampView
    val layoutParams = FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
    //初期のスタンプSCALE
    val DEFAULT_SCALE = 0.7F
    stampViewGroup.layoutParams = layoutParams
    imageViewStamp.setImageResource(StampModel().createStampPickerListModel()[position].drawable)
    stampViewGroup.tag = UUID.randomUUID().toString()
    stampViewGroup.scaleX = stampViewGroup.scaleX - DEFAULT_SCALE
    stampViewGroup.scaleY = stampViewGroup.scaleY - DEFAULT_SCALE
    dragViewListenerListener = DragViewListener(context = this, dragView = stampViewGroup)
    stampViewGroup.setOnTouchListener(dragViewListenerListener)
    return stampViewGroup
}

スタンプのレイアウトファイル

view_stamp.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:orientation="vertical">

    <android.support.constraint.ConstraintLayout
        android:id="@+id/constrainStamp"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margin="0dp">

        <android.support.constraint.ConstraintLayout
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:padding="55dp">

            <ImageView
                android:id="@+id/imageViewStamp"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:background="@drawable/simple_frame"
                android:contentDescription=""
                android:padding="40dp"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintHorizontal_bias="0.0"
                app:layout_constraintLeft_toLeftOf="parent"
                app:layout_constraintRight_toRightOf="parent"
                app:layout_constraintTop_toTopOf="parent"
                app:srcCompat="@drawable/stamp_1"
                tools:ignore="ContentDescription" />

        </android.support.constraint.ConstraintLayout>

    </android.support.constraint.ConstraintLayout>

</LinearLayout>

スタンプの選択時に表示されるボーダー

simple_frame.xml
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="rectangle">
    <stroke android:width="3dp" android:color="#cccccc" />
</shape>

:point_up_2: スタンプを配置したら移動できるようにする

OnTouchListenerを使用します。
そこで以下のように実装します。
onTouchEventからスタンプをタップした時、スタンプを移動させた時、スタンプを離した時のEventを取れるようになります。

private var oldX: Int = 0
private var oldY: Int = 0
override fun onTouch(view: View, event: MotionEvent): Boolean {
        // タッチしている位置取得
        val eventX = event.rawX.toInt()
        val eventY = event.rawY.toInt()
        when (event.action) {
            MotionEvent.ACTION_DOWN -> {
                //stampをタップした時
                //ボーダーをつけたり外したりする処理
                val dragViewParent = dragView.parent as ViewGroup
                (0..dragViewParent.childCount - 1).forEach { i ->
                    val childStampView = dragViewParent.getChildAt(i)
                    val imageViewStamp = childStampView.findViewById(R.id.imageViewStamp)
                    imageViewStamp?.let {
                        if (dragView.tag == childStampView.tag) {
                            imageViewStamp.setBackgroundResource(R.drawable.simple_frame)
                        } else {
                            imageViewStamp.setBackgroundResource(0)
                        }
                    }
                }
               //アクティブなStampを管理するcompanion objectへViewを渡す 
                    StampBehavior.activeStampView = dragView
            }
            MotionEvent.ACTION_MOVE -> {
                // stampを移動させた時
            }
            MotionEvent.ACTION_UP -> {
                // stampを離した時
            }
        }
        // 今回のタッチ位置を保持
        oldX = eventX
        oldY = eventY
        // イベント処理完了
        return true
    }

このあたりは煩雑になりやすいためListenerをまとめておくと良いでしょう。

DragViewListener.kt
class DragViewListener(private val context: Context, private val dragView: ViewGroup) : View.OnTouchListener {
    private var oldX: Int = 0
    private var oldX: Int = 0
    override fun onTouch(view: View, event: MotionEvent): Boolean {
        // タッチしている位置取得
        val eventX = event.rawX.toInt()
        val eventY = event.rawY.toInt()
        when (event.action) {
            MotionEvent.ACTION_DOWN -> {
                //スタンプをタップした時
                //ボーダーをつけたり外したりする処理
                val dragViewParent = dragView.parent as ViewGroup
                (0..dragViewParent.childCount - 1).forEach { i ->
                    val childStampView = dragViewParent.getChildAt(i)
                    val imageViewStamp = childStampView.findViewById(R.id.imageViewStamp)
                    imageViewStamp?.let {
                        if (dragView.tag == childStampView.tag) {
                            imageViewStamp.setBackgroundResource(R.drawable.simple_frame)
                        } else {
                            imageViewStamp.setBackgroundResource(0)
                        }
                    }
                }
               //アクティブなStampを管理するcompanion objectへViewを渡す
               //本来はinterfaceで扱うのが望ましいが説明しやすくしています。
                   StampBehavior.activeStampView = dragView
            }
            MotionEvent.ACTION_MOVE -> {
                //スタンプを移動させた時
            }
            MotionEvent.ACTION_UP -> {
                //スタンプを離した時
            }
        }
        // 今回のタッチ位置を保持
        oldX = eventX
        oldY = eventY
        // イベント処理完了
            return true
        }
    }
    //以下略
}

使い方

var dragViewListener = DragViewListener(context = this, dragView = stampViewGroup)
//スタンプのViewにEventをセットする
stampViewGroup.setOnTouchListener(dragViewListener)

:mag: スタンプを拡大・縮小

配置したスタンプViewのscaleに間隔値をいれます。例えば0.1Fずつ増減させる。
適宜 拡大ボタンをおした時実行すれば拡大縮小します。

今回アクティブな状態(ボーダーが付いた・削除・拡大・縮小できる)のスタンプを
activeStampViewとします。

StampBehavior.kt
//アクティブなViewを管理する 
//本来はinterfaceで扱うのが望ましいが説明しやすくしています。

companion object {
    var activeStampView: View? = null
}
fun scaleController(stampView: View, scale: Float) {
    stampView.scaleX = it.scaleX + scale
    stampView.scaleY = it.scaleY + scale
}

使い方

//拡大
scaleController(stampView = stampView, scale = 0.1F)  
//縮小  
scaleController(stampView = stampView, scale = -0.1F)

:wastebasket: スタンプを削除

配置したスタンプを削除したい場合があります。
こちらは一例ですが以下のようにすると綺麗に消えます。

activeStampView.removeView()

:four: スタンプを押した画像を保存

予め作っておいた画像やスタンプが押される部分のView
frameLayoutImageViewEditStageを画像化してしまいます。

ViewをBitmapへ変換する

こちらもExtensionを以下のように作っておくと楽です。

extensions.kt
fun View.getViewCapture(): Bitmap {
    this.isDrawingCacheEnabled = true
    val cache = this.drawingCache
    val screenShot = Bitmap.createBitmap(cache)
    this.isDrawingCacheEnabled = false
    return screenShot
}

Bitmapを一時ファイルとして保存しUriを返す

Bitmapだと扱うのが大変なため以下のExtensionも併せて作っておくと便利です。
crop処理や圧縮など保存方法のテクニックなどは今回は割愛させていただきます。
キャッシュはどこかで明示的に消してあげると良いでしょう。

extensions.kt
fun Bitmap.getUriFromBitmap(context: Context): Uri {
    val cacheFile = File(context.externalCacheDir,
            System.currentTimeMillis().toString() + ".png")
    var fos: FileOutputStream? = null
    try {
        cacheFile.createNewFile()
        fos = FileOutputStream(cacheFile)
    } catch (e: FileNotFoundException) {
        e.printStackTrace()
    } catch (e: IOException) {
        e.printStackTrace()
    }

    this.compress(Bitmap.CompressFormat.PNG, 100, fos)
    try {
        fos?.close()
    } catch (e: IOException) {
        e.printStackTrace()
    }
    return Uri.fromFile(cacheFile)
}

🎉実際にスタンプ押印後の画像を取得

val stampUri = frameLayoutImageViewEditStage.getViewCapture().getUriFromBitmap(context = this)

おわりに

アクトインディでは以下の職種を募集しています。
https://www.wantedly.com/companies/actindi/projects

以上読んで下さってありがとうございます!

15
20
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
15
20