SubwayTooter-Android-App/app/src/main/java/jp/juggler/subwaytooter/span/NetworkEmojiSpan.kt

292 lines
10 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package jp.juggler.subwaytooter.span
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Rect
import android.graphics.RectF
import android.graphics.drawable.Drawable
import android.text.style.ReplacementSpan
import androidx.annotation.IntRange
import androidx.collection.LruCache
import androidx.core.content.ContextCompat
import jp.juggler.apng.ApngFrames
import jp.juggler.subwaytooter.App1
import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.entity.TootInstance
import jp.juggler.subwaytooter.pref.PrefB
import jp.juggler.subwaytooter.pref.lazyContext
import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.util.log.LogCategory
import java.lang.ref.WeakReference
import kotlin.math.min
enum class EmojiSizeMode {
Square,
Wide,
}
fun SavedAccount?.emojiSizeMode(): EmojiSizeMode {
val ti = this?.let { TootInstance.getCached(it) }
return when {
ti == null -> EmojiSizeMode.Square
ti.isMisskey || !ti.fedibirdCapabilities.isNullOrEmpty() -> EmojiSizeMode.Wide
else -> EmojiSizeMode.Square
}
}
class NetworkEmojiSpan constructor(
private val url: String,
private val sizeMode: EmojiSizeMode,
private val scale: Float = 1f,
private val errorDrawableId: Int = R.drawable.outline_broken_image_24,
) : ReplacementSpan(), AnimatableSpan {
companion object {
internal val log = LogCategory("NetworkEmojiSpan")
private const val scaleRatio = 1.14f
private const val descentRatio = 0.211f
// 最大幅
var maxEmojiWidth = Float.MAX_VALUE
// ImageWidthCache
val imageAspectCache = LruCache<String, Float>(1024)
}
private val mPaint = Paint().apply { isFilterBitmap = true }
private val rectSrc = Rect()
private val rectDst = RectF()
// フレーム探索結果を格納する構造体を確保しておく
private val mFrameFindResult = ApngFrames.FindFrameResult()
private var invalidateCallback: AnimatableSpanInvalidator? = null
private var refDrawTarget: WeakReference<Any>? = null
private var errorDrawableCache: Drawable? = null
private var lastWidth: Float? = null
private var transY = 0f
private var emojiHeight = 0f
private var emojiWidth = 0f
/**
* lastAspect に基づいて rectDst と transY を更新する
*/
private fun updateRect(aspectArg: Float? = null, textSize: Float, baseline: Float) {
// テキストサイズをスケーリングした基本高さ
this.emojiHeight = textSize * scaleRatio * scale
// ベースラインから上下方向にずらすオフセット
val cDescent = emojiHeight * descentRatio
this.transY = baseline - emojiHeight + cDescent
val aspect = when(aspectArg){
null ->{
imageAspectCache[url] ?: 1f
}
else->{
imageAspectCache.put(url,aspectArg)
aspectArg
}
}.takeIf { it>0f }?:1f
when {
// 横長画像で、それを許可するモード
aspect > 1.36f && sizeMode == EmojiSizeMode.Wide -> {
// 絵文字のアスペクト比から描画範囲の幅と高さを決める
val dstWidth = min(maxEmojiWidth, aspect * emojiHeight)
val dstHeight = dstWidth / aspect
val dstX = 0f
val dstY = (emojiHeight - dstHeight) / 2f
rectDst.set(dstX, dstY, dstX + dstWidth, dstY + dstHeight)
emojiWidth = dstWidth
}
else -> {
emojiWidth = emojiHeight
// 絵文字のアスペクト比から描画範囲の幅と高さを決める
val dstWidth: Float
val dstHeight: Float
if (aspect >= 1f) {
dstWidth = emojiHeight
dstHeight = emojiHeight / aspect
} else {
dstHeight = emojiHeight
dstWidth = emojiHeight * aspect
}
val dstX = (emojiHeight - dstWidth) / 2f
val dstY = (emojiHeight - dstHeight) / 2f
rectDst.set(dstX, dstY, dstX + dstWidth, dstY + dstHeight)
}
}
// 出力サイズが変化したならrequestLayout
val newWidth = rectDst.width()
if (lastWidth != null && lastWidth != newWidth) {
log.i("updateRect: width changed. $lastWidth$newWidth")
invalidateCallback?.requestLayout()
}
lastWidth = newWidth
}
override fun setInvalidateCallback(
drawTargetTag: Any,
invalidateCallback: AnimatableSpanInvalidator,
) {
this.refDrawTarget = WeakReference(drawTargetTag)
this.invalidateCallback = invalidateCallback
}
override fun getSize(
paint: Paint,
text: CharSequence,
@IntRange(from = 0) start: Int,
@IntRange(from = 0) end: Int,
fm: Paint.FontMetricsInt?,
): Int {
updateRect(aspectArg = null, paint.textSize, baseline = 0f)
val height = (emojiHeight + 0.5f).toInt()
if (fm != null) {
val cDescent = (0.5f + height * descentRatio).toInt()
val cAscent = cDescent - height
if (fm.ascent > cAscent) fm.ascent = cAscent
if (fm.top > cAscent) fm.top = cAscent
if (fm.descent < cDescent) fm.descent = cDescent
if (fm.bottom < cDescent) fm.bottom = cDescent
}
return (emojiWidth + 0.5f).toInt()
}
override fun draw(
canvas: Canvas,
text: CharSequence,
start: Int,
end: Int,
x: Float,
top: Int,
baseline: Int,
bottom: Int,
textPaint: Paint,
) {
if (drawFrame(canvas, x, baseline, textPaint)) return
drawError(canvas, x, baseline, textPaint)
}
private fun drawFrame(
canvas: Canvas,
x: Float,
baseline: Int,
textPaint: Paint,
): Boolean {
val invalidateCallback = this.invalidateCallback
if (invalidateCallback == null) {
log.e("draw: invalidate_callback is null.")
return false
}
// APNGデータの取得
val frames = App1.custom_emoji_cache.getFrames(refDrawTarget, url) {
invalidateCallback.delayInvalidate(0L)
} ?: return false
val t = when {
PrefB.bpDisableEmojiAnimation.value -> 0L
else -> invalidateCallback.timeFromStart
}
// アニメーション開始時刻からの経過時間に応じたフレームを探索
frames.findFrame(mFrameFindResult, t)
val b = mFrameFindResult.bitmap
if (b == null || b.isRecycled) {
log.e("draw: bitmap is null or recycled.")
return false
}
val srcWidth = b.width
val srcHeight = b.height
if (srcWidth < 1 || srcHeight < 1) {
log.e("draw: bitmap size is too small.")
return false
}
rectSrc.set(0, 0, srcWidth, srcHeight)
updateRect(
aspectArg = srcWidth.toFloat() / srcHeight.toFloat(),
textPaint.textSize,
baseline.toFloat()
)
canvas.save()
try {
canvas.translate(x, transY)
canvas.drawBitmap(b, rectSrc, rectDst, mPaint)
} catch (ex: Throwable) {
log.w(ex, "drawBitmap failed.")
// 10月6日 18:18アプリのバージョン: 378 Sony Xperia X CompactF5321, Android 8.0
// 10月6日 11:35アプリのバージョン: 380 Samsung Galaxy S7 Edgehero2qltetmo, Android 8.0
// 10月2日 21:56アプリのバージョン: 376 Google Pixel 3blueline, Android 9
// java.lang.RuntimeException:
// at android.graphics.BaseCanvas.throwIfCannotDraw (BaseCanvas.java:55)
// at android.view.DisplayListCanvas.throwIfCannotDraw (DisplayListCanvas.java:226)
// at android.view.RecordingCanvas.drawBitmap (RecordingCanvas.java:123)
// at jp.juggler.subwaytooter.span.NetworkEmojiSpan.draw (NetworkEmojiSpan.kt:137)
} finally {
canvas.restore()
}
// 少し後に描画しなおす
val delay = mFrameFindResult.delay
if (delay != Long.MAX_VALUE && !PrefB.bpDisableEmojiAnimation.value) {
invalidateCallback.delayInvalidate(delay)
}
return true
}
private fun drawError(
canvas: Canvas,
x: Float,
baseline: Int,
textPaint: Paint,
) {
val drawable = errorDrawableCache
?: ContextCompat.getDrawable(lazyContext, errorDrawableId)
?.also { errorDrawableCache = it }
drawable ?: return
val srcWidth = drawable.intrinsicWidth.toFloat()
val srcHeight = drawable.intrinsicHeight.toFloat()
updateRect(
aspectArg = srcWidth / srcHeight,
textSize = textPaint.textSize,
baseline = baseline.toFloat()
)
canvas.save()
try {
canvas.translate(x, transY)
drawable.setBounds(
rectDst.left.toInt(),
rectDst.top.toInt(),
rectDst.right.plus(0.5f).toInt(),
rectDst.bottom.plus(0.5f).toInt(),
)
drawable.draw(canvas)
} catch (ex: Throwable) {
log.w(ex, "drawBitmap failed.")
// 10月6日 18:18アプリのバージョン: 378 Sony Xperia X CompactF5321, Android 8.0
// 10月6日 11:35アプリのバージョン: 380 Samsung Galaxy S7 Edgehero2qltetmo, Android 8.0
// 10月2日 21:56アプリのバージョン: 376 Google Pixel 3blueline, Android 9
// java.lang.RuntimeException:
// at android.graphics.BaseCanvas.throwIfCannotDraw (BaseCanvas.java:55)
// at android.view.DisplayListCanvas.throwIfCannotDraw (DisplayListCanvas.java:226)
// at android.view.RecordingCanvas.drawBitmap (RecordingCanvas.java:123)
// at jp.juggler.subwaytooter.span.NetworkEmojiSpan.draw (NetworkEmojiSpan.kt:137)
} finally {
canvas.restore()
}
}
}