自己紹介
こんにちわ
株式会社アクトインディ アプリチームのspelunker_muzです。
弊社のアプリ「いこーよ」はKotlin開発しています。
Androidでスタンプ機能を実現するため実装内容をまとめました。
やりたいこと
写真を撮影またはギャラリーから写真をピックアップしてスタンプを押したい。
写真に顔が写った際、顔だけ隠したい。
こんな感じです。
仕様について
スタンプを押したい画像に対し画面下部のRecyclerViewのスタンプピッカーからスタンプを選択。
画像の上にスタンプを配置。配置したViewを画像化して保存。
スタンプは拡大・縮小・移動が可能。
スタンプをタップしアクティブ時には四隅ボーダーを付け
非アクティブ時にボーダーを非表示にする。
操作手順
写真を撮影
撮影した写真をViewに配置
スタンプを押す
ViewをBitmapへ変換する
早速説明に入ります。
写真を撮影
Extensionで以下のコードを追加。
fun Activity.startActivityForResultCamera(cameraUri: Uri) {
val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
intent.putExtra(MediaStore.EXTRA_OUTPUT, cameraUri)
this.startActivityForResult(intent, RequestCode.Camera.ordinal)
}
今回は最低限の実装を以下にて書きました。
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
撮影した写真をViewに配置
で取得した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を渡す。
スタンプを押す
さてここからが本題です。
,の手順で写真撮影して画像を配置する。ここまではなんの問題なく実装をしてきました。
ここから実際のスタンプ処理に触れたいと思います。
スタンプ押印画面はざっくり
画面上部はスタンプを押したい画像を配置。
その上にスタンプをコントロールするためのボタンUIを配置(削除・拡大・縮小)
画面下部分はスタンプをピックアップする部分、よくLINEなんかであるスタンプピッカー。
スタンプ押印にはスタンプピッカーをタップで選び配置する。
具体的には画面下部にRecyclerViewでスタンプピッカーを作成し選択できるようにする。
ここではframeLayoutImageViewEditStage(任意命名)というId名でViewを作成しておき
スタンプを押す画像、スタンプを配置していきます。
Viewのレイヤー構造は
スタンプのコントローラー(削除・拡大・縮小ボタン)
スタンプ
スタンプを押したい画像
スタンプのピッカーを作る
今回はシンプルなRecyclerViewで作ります。(最低限実装)
これです。
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
}
}
}
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などコピペしやすいようベタ書きしてしまいます
<?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" />
<?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())
スタンプを配置する
スタンプピッカーの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
}
スタンプのレイアウトファイル
<?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>
スタンプの選択時に表示されるボーダー
<?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>
スタンプを配置したら移動できるようにする
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をまとめておくと良いでしょう。
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)
スタンプを拡大・縮小
配置したスタンプViewのscaleに間隔値をいれます。例えば0.1Fずつ増減させる。
適宜 拡大ボタンをおした時実行すれば拡大縮小します。
今回アクティブな状態(ボーダーが付いた・削除・拡大・縮小できる)のスタンプを
activeStampViewとします。
//アクティブな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)
スタンプを削除
配置したスタンプを削除したい場合があります。
こちらは一例ですが以下のようにすると綺麗に消えます。
activeStampView.removeView()
スタンプを押した画像を保存
予め作っておいた画像やスタンプが押される部分のView
frameLayoutImageViewEditStageを画像化してしまいます。
ViewをBitmapへ変換する
こちらもExtensionを以下のように作っておくと楽です。
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処理や圧縮など保存方法のテクニックなどは今回は割愛させていただきます。
キャッシュはどこかで明示的に消してあげると良いでしょう。
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
以上読んで下さってありがとうございます!