SubwayTooter-Android-App/app/src/main/java/jp/juggler/subwaytooter/view/PinchBitmapView.kt

507 lines
14 KiB
Kotlin

package jp.juggler.subwaytooter.view
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Matrix
import android.graphics.Paint
import android.os.SystemClock
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.VelocityTracker
import android.view.View
import jp.juggler.util.LogCategory
import jp.juggler.util.runOnMainLooper
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.sqrt
class PinchBitmapView(context : Context, attrs : AttributeSet?, defStyle : Int) :
View(context, attrs, defStyle) {
companion object {
internal val log = LogCategory("PinchImageView")
// 数値を範囲内にクリップする
private fun clip(min : Float, max : Float, v : Float) : Float {
return if(v < min) min else if(v > max) max else v
}
// ビューの幅と画像の描画サイズを元に描画位置をクリップする
private fun clipTranslate(
view_w : Float // ビューの幅
, bitmap_w : Float // 画像の幅
, current_scale : Float // 画像の拡大率
, trans_x : Float // タッチ操作による表示位置
) : Float {
// 余白(拡大率が小さい場合はプラス、拡大率が大きい場合はマイナス)
val padding = view_w - bitmap_w * current_scale
// 余白が>=0なら画像を中心に表示する。 <0なら操作された位置をクリップする。
return if(padding >= 0f) padding / 2f else clip(padding, 0f, trans_x)
}
}
private var callback : Callback? = null
private var bitmap : Bitmap? = null
private var bitmap_w : Float = 0.toFloat()
private var bitmap_h : Float = 0.toFloat()
private var bitmap_aspect : Float = 0.toFloat()
// 画像を表示する位置と拡大率
private var current_trans_x : Float = 0.toFloat()
private var current_trans_y : Float = 0.toFloat()
private var current_scale : Float = 0.toFloat()
// 画像表示に使う構造体
private val drawMatrix = Matrix()
internal val paint = Paint()
// タッチ操作中に指を動かした
private var bDrag : Boolean = false
// タッチ操作中に指の数を変えた
private var bPointerCountChanged : Boolean = false
// ページめくりに必要なスワイプ強度
private var swipe_velocity = 0f
private var swipe_velocity2 = 0f
// 指を動かしたと判断する距離
private var drag_length = 0f
private var time_touch_start = 0L
// フリック操作の検出に使う
private var velocityTracker : VelocityTracker? = null
private var click_time = 0L
private var click_count = 0
// 移動後の指の位置
internal val pos = PointerAvg()
// 移動開始時の指の位置
private val start_pos = PointerAvg()
// 移動開始時の画像の位置
private var start_image_trans_x : Float = 0.toFloat()
private var start_image_trans_y : Float = 0.toFloat()
private var start_image_scale : Float = 0.toFloat()
private var scale_min : Float = 0.toFloat()
private var scale_max : Float = 0.toFloat()
private var view_w : Float = 0.toFloat()
private var view_h : Float = 0.toFloat()
private var view_aspect : Float = 0.toFloat()
private val tracking_matrix = Matrix()
private val tracking_matrix_inv = Matrix()
private val avg_on_image1 = FloatArray(2)
private val avg_on_image2 = FloatArray(2)
constructor(context : Context) : this(context, null) {
init(context)
}
constructor(context : Context, attrs : AttributeSet?) : this(context, attrs, 0) {
init(context)
}
init {
init(context)
}
internal fun init(context : Context) {
// 定数をdpからpxに変換
val density = context.resources.displayMetrics.density
swipe_velocity = 1000f * density
swipe_velocity2 = 250f * density
drag_length = 4f * density // 誤反応しがちなのでやや厳しめ
}
// ページめくり操作のコールバック
interface Callback {
fun onSwipe(deltaX : Int, deltaY : Int)
fun onMove(bitmap_w : Float, bitmap_h : Float, tx : Float, ty : Float, scale : Float)
}
fun setCallback(callback : Callback?) {
this.callback = callback
}
fun setBitmap(b : Bitmap?) {
bitmap?.recycle()
this.bitmap = b
initializeScale()
}
override fun onDraw(canvas : Canvas) {
super.onDraw(canvas)
val bitmap = this.bitmap
if(bitmap != null && ! bitmap.isRecycled) {
drawMatrix.reset()
drawMatrix.postScale(current_scale, current_scale)
drawMatrix.postTranslate(current_trans_x, current_trans_y)
paint.isFilterBitmap = current_scale < 4f
canvas.drawBitmap(bitmap, drawMatrix, paint)
}
}
override fun onSizeChanged(w : Int, h : Int, oldw : Int, oldh : Int) {
super.onSizeChanged(w, h, oldw, oldh)
view_w = Math.max(1f, w.toFloat())
view_h = Math.max(1f, h.toFloat())
view_aspect = view_w / view_h
initializeScale()
}
override fun performClick() : Boolean {
super.performClick()
initializeScale()
return true
}
private var defaultScale : Float = 1f
// 表示位置の初期化
// 呼ばれるのは、ビットマップを変更した時、ビューのサイズが変わった時、画像をクリックした時
private fun initializeScale() {
val bitmap = this.bitmap
if(bitmap != null && ! bitmap.isRecycled && view_w >= 1f) {
bitmap_w = Math.max(1f, bitmap.width.toFloat())
bitmap_h = Math.max(1f, bitmap.height.toFloat())
bitmap_aspect = bitmap_w / bitmap_h
if(view_aspect > bitmap_aspect) {
scale_min = view_h / bitmap_h / 2f
scale_max = view_w / bitmap_w * 8f
} else {
scale_min = view_w / bitmap_w / 2f
scale_max = view_h / bitmap_h * 8f
}
if(scale_max < scale_min) scale_max = scale_min * 16f
defaultScale = if(view_aspect > bitmap_aspect) {
view_h / bitmap_h
} else {
view_w / bitmap_w
}
val draw_w = bitmap_w * defaultScale
val draw_h = bitmap_h * defaultScale
current_scale = defaultScale
current_trans_x = (view_w - draw_w) / 2f
current_trans_y = (view_h - draw_h) / 2f
callback?.onMove(bitmap_w, bitmap_h, current_trans_x, current_trans_y, current_scale)
} else {
defaultScale = 1f
scale_min = 1f
scale_max = 1f
current_scale = defaultScale
current_trans_y = 0f
current_trans_x = 0f
callback?.onMove(0f, 0f, current_trans_x, current_trans_y, current_scale)
}
// 画像がnullに変化した時も再描画が必要
invalidate()
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(ev : MotionEvent) : Boolean {
val bitmap = this.bitmap
if(bitmap == null
|| bitmap.isRecycled
|| view_w < 1f)
return false
val action = ev.action
if(action == MotionEvent.ACTION_DOWN) {
time_touch_start = SystemClock.elapsedRealtime()
velocityTracker?.clear()
velocityTracker = VelocityTracker.obtain()
velocityTracker?.addMovement(ev)
bPointerCountChanged = false
bDrag = bPointerCountChanged
trackStart(ev)
return true
}
velocityTracker?.addMovement(ev)
when(action) {
MotionEvent.ACTION_POINTER_DOWN, MotionEvent.ACTION_POINTER_UP -> {
// タッチ操作中に指の数を変えた
bPointerCountChanged = true
bDrag = bPointerCountChanged
trackStart(ev)
}
MotionEvent.ACTION_MOVE -> trackNext(ev)
MotionEvent.ACTION_UP -> {
trackNext(ev)
checkClickOrPaging()
velocityTracker?.recycle()
velocityTracker = null
}
}
return true
}
private fun checkClickOrPaging() {
if(! bDrag) {
// 指を動かしていないなら
val now = SystemClock.elapsedRealtime()
if(now - time_touch_start >= 1000L) {
// ロングタップはタップカウントをリセットする
log.d("click count reset by long tap")
click_count = 0
return
}
val delta = now - click_time
click_time = now
if(delta > 334L) {
// 前回のタップからの時刻が長いとタップカウントをリセットする
log.d("click count reset by long interval")
click_count = 0
}
++ click_count
log.d("click %d %d", click_count, delta)
if(click_count >= 2) {
// ダブルタップでクリック操作
click_count = 0
performClick()
}
return
}
click_count = 0
val velocityTracker = this.velocityTracker
if(! bPointerCountChanged && velocityTracker != null) {
// 指の数を変えていないならページめくり操作かもしれない
// 「画像を動かした」かどうかのチェック
val image_moved = max(
abs(current_trans_x - start_image_trans_x),
abs(current_trans_y - start_image_trans_y)
)
if(image_moved >= drag_length) {
log.d("image moved. not flick action. $image_moved")
return
}
velocityTracker.computeCurrentVelocity(1000)
val vx = velocityTracker.xVelocity
val vy = velocityTracker.yVelocity
val avx = abs(vx)
val avy = abs(vy)
val velocity = sqrt(vx * vx + vy * vy)
val aspect = try {
avx / avy
} catch(ex : Throwable) {
Float.MAX_VALUE
}
when {
aspect >= 0.9f -> {
// 指を動かした方向が左右だった
val vMin = when {
current_scale * bitmap_w <= view_w -> swipe_velocity2
else -> swipe_velocity
}
if(velocity < vMin) {
log.d("velocity $velocity not enough to pagingX")
return
}
log.d("pagingX! m=$image_moved a=$aspect v=$velocity")
runOnMainLooper { callback?.onSwipe(if(vx >= 0f) - 1 else 1, 0) }
}
aspect <= 0.333f -> {
// 指を動かした方向が上下だった
val vMin = when {
current_scale * bitmap_h <= view_h -> swipe_velocity2
else -> swipe_velocity
}
if(velocity < vMin) {
log.d("velocity $velocity not enough to pagingY")
return
}
log.d("pagingY! m=$image_moved a=$aspect v=$velocity")
runOnMainLooper { callback?.onSwipe(0, if(vy >= 0f) - 1 else 1) }
}
else -> log.d("flick is not horizontal/vertical. aspect=$aspect")
}
}
}
// マルチタッチの中心位置の計算
internal class PointerAvg {
// タッチ位置の数
var count : Int = 0
// タッチ位置の平均
val avg = FloatArray(2)
// 中心と、中心から最も離れたタッチ位置の間の距離
var max_radius : Float = 0.toFloat()
fun update(ev : MotionEvent) {
count = ev.pointerCount
if(count <= 1) {
avg[0] = ev.x
avg[1] = ev.y
max_radius = 0f
} else {
avg[0] = 0f
avg[1] = 0f
for(i in 0 until count) {
avg[0] += ev.getX(i)
avg[1] += ev.getY(i)
}
avg[0] /= count.toFloat()
avg[1] /= count.toFloat()
max_radius = 0f
for(i in 0 until count) {
val dx = ev.getX(i) - avg[0]
val dy = ev.getY(i) - avg[1]
val radius = dx * dx + dy * dy
if(radius > max_radius) max_radius = radius
}
max_radius = Math.sqrt(max_radius.toDouble()).toFloat()
if(max_radius < 1f) max_radius = 1f
}
}
}
private fun trackStart(ev : MotionEvent) {
// 追跡開始時の指の位置
start_pos.update(ev)
// 追跡開始時の画像の位置
start_image_trans_x = current_trans_x
start_image_trans_y = current_trans_y
start_image_scale = current_scale
}
// 画面上の指の位置から画像中の指の位置を調べる
private fun getCoordinateOnImage(dst : FloatArray, src : FloatArray) {
tracking_matrix.reset()
tracking_matrix.postScale(current_scale, current_scale)
tracking_matrix.postTranslate(current_trans_x, current_trans_y)
tracking_matrix.invert(tracking_matrix_inv)
tracking_matrix_inv.mapPoints(dst, src)
}
private fun trackNext(ev : MotionEvent) {
pos.update(ev)
if(pos.count != start_pos.count) {
// タッチ操作中に指の数が変わった
log.d("nextTracking: pointer count changed")
bPointerCountChanged = true
bDrag = bPointerCountChanged
trackStart(ev)
return
}
// ズーム操作
if(pos.count > 1) {
// タッチ位置にある絵柄の座標を調べる
getCoordinateOnImage(avg_on_image1, pos.avg)
// ズーム率を変更する
current_scale = clip(
scale_min,
scale_max,
start_image_scale * pos.max_radius / start_pos.max_radius
)
// 再び調べる
getCoordinateOnImage(avg_on_image2, pos.avg)
// ズーム変更の前後で位置がズレた分だけ移動させると、タッチ位置にある絵柄がズレない
start_image_trans_x += current_scale * (avg_on_image2[0] - avg_on_image1[0])
start_image_trans_y += current_scale * (avg_on_image2[1] - avg_on_image1[1])
}
// 平行移動
run {
// start時から指を動かした量
val move_x = pos.avg[0] - start_pos.avg[0]
val move_y = pos.avg[1] - start_pos.avg[1]
// 「指を動かした」と判断したらフラグを立てる
if(Math.abs(move_x) >= drag_length || Math.abs(move_y) >= drag_length) {
bDrag = true
}
// 画像の表示位置を更新
current_trans_x =
clipTranslate(view_w, bitmap_w, current_scale, start_image_trans_x + move_x)
current_trans_y =
clipTranslate(view_h, bitmap_h, current_scale, start_image_trans_y + move_y)
}
callback?.onMove(bitmap_w, bitmap_h, current_trans_x, current_trans_y, current_scale)
invalidate()
}
}