Rewrite EmojiAction

* Use RecyclerView for significantly better performance
* Skin tone selector popup
This commit is contained in:
Aleksandras Kostarevas 2024-01-11 21:45:22 +02:00
parent 2188a2b03a
commit 4d5359fb59
6 changed files with 573 additions and 172 deletions

View File

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?><!--
Copyright 2022 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="?android:attr/colorAccent">
<item android:id="@android:id/mask">
<shape android:shape="rectangle">
<corners android:radius="12dp" />
<solid android:color="@android:color/white" />
</shape>
</item>
</ripple>

View File

@ -0,0 +1,26 @@
<!--
Copyright 2022 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0"
android:tint="?android:attr/colorButtonNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M2,22h20V2L2,22z"/>
</vector>

View File

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?><!--
Copyright 2022 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<resources>
<!-- Describes the category list in the emoji picker header view. -->
<dimen name="emoji_picker_header_icon_holder_width">39dp</dimen>
<dimen name="emoji_picker_header_icon_holder_min_height">46dp</dimen>
<dimen name="emoji_picker_header_icon_width">20dp</dimen>
<dimen name="emoji_picker_header_icon_height">20dp</dimen>
<dimen name="emoji_picker_header_icon_underline_width">28dp</dimen>
<dimen name="emoji_picker_header_icon_underline_height">2dp</dimen>
<dimen name="emoji_picker_header_height">42dp</dimen>
<dimen name="emoji_picker_header_padding">5dp</dimen>
<dimen name="variant_availability_indicator_height">5dp</dimen>
<dimen name="variant_availability_indicator_width">5dp</dimen>
<dimen name="emoji_picker_popup_view_holder_padding_vertical">10dp</dimen>
<dimen name="emoji_picker_popup_view_holder_padding_start">10dp</dimen>
<dimen name="emoji_picker_popup_view_holder_padding_end">10dp</dimen>
<dimen name="emoji_picker_popup_view_elevation">8dp</dimen>
<dimen name="emoji_picker_popup_view_holder_corner_radius">30dp</dimen>
<dimen name="emoji_picker_skin_tone_circle_radius">6dp</dimen>
<dimen name="emoji_picker_category_name_height">24dp</dimen>
<dimen name="emoji_picker_category_name_padding_top">4dp</dimen>
<dimen name="emoji_picker_emoji_view_padding">2dp</dimen>
</resources>

View File

@ -1,25 +1,19 @@
package org.futo.inputmethod.latin.uix.actions
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.Paint
import android.text.TextPaint
import android.util.TypedValue
import android.graphics.Rect
import android.view.ViewGroup
import android.view.accessibility.AccessibilityEvent
import androidx.annotation.UiThread
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
@ -28,29 +22,37 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.graphics.applyCanvas
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.PopupProperties
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
@ -59,172 +61,244 @@ import org.futo.inputmethod.latin.common.Constants
import org.futo.inputmethod.latin.uix.Action
import org.futo.inputmethod.latin.uix.ActionWindow
import org.futo.inputmethod.latin.uix.PersistentActionState
import kotlin.math.ceil
import org.futo.inputmethod.latin.uix.actions.emoji.EmojiItem
import org.futo.inputmethod.latin.uix.actions.emoji.EmojiView
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
data class EmojiItem(
val emoji: String,
val description: String,
val category: String
)
data class PopupInfo(val emoji: EmojiItem, val x: Int, val y: Int)
const val EMOJI_HEIGHT = 30.0f //sp
// Note: Using traditional View here, because Android Compose leaves a lot of performance to be desired
class EmojiGridAdapter(
private val emojiList: List<EmojiItem>,
private val onClick: (EmojiItem) -> Unit,
private val onSelectSkinTone: (PopupInfo) -> Unit,
private val emojiCellWidth: Int
) :
RecyclerView.Adapter<EmojiGridAdapter.EmojiViewHolder>() {
data class BitmapRecycler(
private val freeBitmaps: MutableList<Bitmap> = mutableListOf()
) {
var textPaint: TextPaint? = null
var total = 0
fun getTextPaint(context: Context): TextPaint {
return textPaint ?: run {
this.textPaint = TextPaint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG).apply {
textSize = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_SP,
EMOJI_HEIGHT,
context.resources.displayMetrics
)
class EmojiViewHolder(
context: Context,
width: Int,
height: Int
) : RecyclerView.ViewHolder(EmojiView(context)) {
private val emojiView: EmojiView = (itemView as EmojiView).apply {
layoutParams = ViewGroup.LayoutParams(width, height)
isClickable = true
}
fun bindEmoji(
emoji: EmojiItem,
onClick: (EmojiItem) -> Unit,
onSelectSkinTone: (PopupInfo) -> Unit
) {
emojiView.emoji = emoji
emojiView.setOnClickListener {
it.sendAccessibilityEvent(AccessibilityEvent.TYPE_ANNOUNCEMENT)
onClick(emoji)
}
this.textPaint!!
emojiView.isLongClickable = emoji.skinTones
if (emoji.skinTones) {
emojiView.setOnLongClickListener {
var rect = Rect()
it.getGlobalVisibleRect(rect)
onSelectSkinTone(PopupInfo(emoji, rect.centerX(), rect.centerY()))
emojiView.isLongClickable
}
}
}
}
fun getBitmap(): Bitmap {
return freeBitmaps.removeFirstOrNull()?.apply {
eraseColor(Color.TRANSPARENT)
} ?: with(textPaint!!.fontMetricsInt) {
println("creating new bitmap, total $total")
total += 1
val size = bottom - top
Bitmap.createBitmap(size, size, android.graphics.Bitmap.Config.ARGB_8888)
}
@UiThread
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EmojiViewHolder {
return EmojiViewHolder(parent.context, width = emojiCellWidth, height = emojiCellWidth)
}
fun freeBitmap(bitmap: Bitmap) {
if(freeBitmaps.size > 60) {
println("Recycling bitmap, new total $total")
total -= 1
bitmap.recycle()
} else {
freeBitmaps.add(bitmap)
}
override fun onBindViewHolder(holder: EmojiViewHolder, position: Int) {
val currentEmoji = emojiList[position]
holder.bindEmoji(currentEmoji, onClick, onSelectSkinTone)
}
fun freeAllBitmaps() {
freeBitmaps.forEach {
println("Recycling bitmap due to freeAllBitmaps, new total $total")
total -= 1
it.recycle()
}
freeBitmaps.clear()
}
override fun getItemCount() = emojiList.size
}
@Composable fun EmojiIcon(emoji: String, bitmaps: BitmapRecycler) {
var rendering by remember { mutableStateOf(true) }
val offscreenCanvasBitmap: Bitmap = remember { bitmaps.getBitmap() }
val imageBitmap = remember { offscreenCanvasBitmap.asImageBitmap() }
DisposableEffect(offscreenCanvasBitmap) {
onDispose {
bitmaps.freeBitmap(bitmap = offscreenCanvasBitmap)
}
}
val skinTones = listOf(
"\ud83c\udffb",
"\ud83c\udffc",
"\ud83c\udffd",
"\ud83c\udffe",
"\ud83c\udfff",
)
LaunchedEffect(emoji) {
withContext(Dispatchers.Unconfined) {
val textPaint = bitmaps.textPaint!!
yield()
offscreenCanvasBitmap.applyCanvas {
yield()
val textWidth = textPaint.measureText(emoji, 0, emoji.length)
yield()
drawText(
emoji,
/* start = */ 0,
/* end = */ emoji.length,
/* x = */ (width - textWidth) / 2,
/* y = */ -textPaint.fontMetrics.top,
textPaint,
)
yield()
// TODO: Mixing multiple skin tones
// e.g. family: woman, woman, girl, girl: medium, dark. light, medium skin tones
fun generateSkinToneVariants(emoji: String, emojiMap: Map<String, EmojiItem>): List<String> {
val humanEmojis = emoji.split("\u200D")
val variants = mutableListOf<String>()
for (modifier in skinTones) {
val variant = humanEmojis.joinToString("\u200D") { part ->
if (emojiMap[part]?.category == "People & Body") {
part + modifier
} else {
part
}
yield()
rendering = false
}
variants.add(variant)
}
return variants
}
@Composable
fun Emojis(
emojis: List<EmojiItem>,
onClick: (EmojiItem) -> Unit,
modifier: Modifier = Modifier,
emojiMap: Map<String, EmojiItem>
) {
val emojiWidth = with(LocalDensity.current) {
remember {
42.dp.toPx().roundToInt()
}
}
Image(
bitmap = imageBitmap,
contentDescription = emoji,
modifier = Modifier.fillMaxSize()
var activePopup: PopupInfo? by remember { mutableStateOf(null) }
val emojiAdapter = remember {
EmojiGridAdapter(
emojis,
onClick,
onSelectSkinTone = { activePopup = it },
emojiWidth
)
}
var viewWidth by remember { mutableStateOf(0) }
activePopup?.let { popupInfo ->
Popup(
onDismissRequest = {
activePopup = null
},
properties = PopupProperties(
clippingEnabled = false
),
popupPositionProvider = object : PopupPositionProvider {
override fun calculatePosition(
anchorBounds: IntRect,
windowSize: IntSize,
layoutDirection: LayoutDirection,
popupContentSize: IntSize
): IntOffset {
val posX = popupInfo.x - popupContentSize.width / 2
val posY = popupInfo.y - popupContentSize.height
// Calculate the maximum possible x and y values
val maxX = windowSize.width - popupContentSize.width
val maxY = windowSize.height - popupContentSize.height
// Calculate the x and y values, clamping them to the maximum values if necessary
val x = min(maxX, max(0, posX))
val y = min(maxY, max(0, posY))
return IntOffset(x, y)
}
}
) {
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
shape = RoundedCornerShape(8.dp)
) {
Row {
generateSkinToneVariants(popupInfo.emoji.emoji, emojiMap).map { emoji ->
IconButton(onClick = {
onClick(
EmojiItem(
emoji = emoji,
description = popupInfo.emoji.description,
category = popupInfo.emoji.category,
skinTones = false
)
)
activePopup = null
}, modifier = Modifier
.width(42.dp)
.height(42.dp)) {
Box {
Text(emoji, modifier = Modifier.align(Alignment.Center))
}
}
}
}
}
}
}
AndroidView(
factory = { context ->
RecyclerView(context).apply {
layoutManager = GridLayoutManager(context, 8)
adapter = emojiAdapter
}
},
update = {
if (viewWidth > 0) {
(it.layoutManager as GridLayoutManager).spanCount = viewWidth / emojiWidth
}
},
modifier = modifier
.clipToBounds()
.onSizeChanged {
viewWidth = it.width
}
)
}
@Composable
fun EmojiGrid(onClick: (EmojiItem) -> Unit, onExit: () -> Unit, onBackspace: () -> Unit, onSpace: () -> Unit, bitmaps: BitmapRecycler, emojis: List<EmojiItem>, keyboardShown: Boolean) {
val context = LocalContext.current
val spToDp = context.resources.displayMetrics.scaledDensity / context.resources.displayMetrics.density
Column {
LazyVerticalGrid(
columns = GridCells.Adaptive((40.sp * spToDp).value.dp),
contentPadding = PaddingValues(10.dp),
modifier = Modifier.weight(1.0f)
) {
items(emojis, key = { it.emoji }) { emoji ->
Box(modifier = Modifier
.fillMaxSize()
.clickable {
onClick(emoji)
}) {
EmojiIcon(emoji.emoji, bitmaps)
}
fun BottomRowKeyboardNavigation(onExit: () -> Unit, onBackspace: () -> Unit, onSpace: () -> Unit) {
Surface(
color = MaterialTheme.colorScheme.background, modifier = Modifier
.fillMaxWidth()
.height(48.dp)
) {
Row(modifier = Modifier.padding(2.dp, 8.dp, 2.dp, 0.dp)) {
IconButton(onClick = { onExit() }) {
Text("ABC", fontSize = 14.sp)
}
}
if(!keyboardShown) {
Surface(
color = MaterialTheme.colorScheme.background, modifier = Modifier
.fillMaxWidth()
.height(48.dp)
Button(
onClick = { onSpace() }, modifier = Modifier
.weight(1.0f)
.padding(8.dp, 2.dp), colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.33f),
contentColor = MaterialTheme.colorScheme.onBackground,
disabledContainerColor = MaterialTheme.colorScheme.outline,
disabledContentColor = MaterialTheme.colorScheme.onBackground,
), shape = RoundedCornerShape(32.dp)
) {
Row(modifier = Modifier.padding(2.dp, 8.dp, 2.dp, 0.dp)) {
IconButton(onClick = { onExit() }) {
Text("ABC", fontSize = 14.sp)
}
Text("")
}
Button(
onClick = { onSpace() }, modifier = Modifier
.weight(1.0f)
.padding(8.dp, 2.dp), colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.33f),
contentColor = MaterialTheme.colorScheme.onBackground,
disabledContainerColor = MaterialTheme.colorScheme.outline,
disabledContentColor = MaterialTheme.colorScheme.onBackground,
), shape = RoundedCornerShape(32.dp)
IconButton(onClick = { onBackspace() }) {
val icon = painterResource(id = R.drawable.delete)
val iconColor = MaterialTheme.colorScheme.onBackground
Canvas(modifier = Modifier.fillMaxSize()) {
translate(
left = this.size.width / 2.0f - icon.intrinsicSize.width / 2.0f,
top = this.size.height / 2.0f - icon.intrinsicSize.height / 2.0f
) {
Text("")
}
IconButton(onClick = { onBackspace() }) {
val icon = painterResource(id = R.drawable.delete)
val iconColor = MaterialTheme.colorScheme.onBackground
Canvas(modifier = Modifier.fillMaxSize()) {
translate(
left = this.size.width / 2.0f - icon.intrinsicSize.width / 2.0f,
top = this.size.height / 2.0f - icon.intrinsicSize.height / 2.0f
) {
with(icon) {
draw(
icon.intrinsicSize,
colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(
iconColor
)
)
}
}
with(icon) {
draw(
icon.intrinsicSize,
colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(
iconColor
)
)
}
}
}
@ -233,22 +307,40 @@ fun EmojiGrid(onClick: (EmojiItem) -> Unit, onExit: () -> Unit, onBackspace: ()
}
}
/*
@Preview(showBackground = true)
@Composable
fun EmojiGridPreview() {
EmojiGrid(
onBackspace = {},
onClick = {},
onExit = {},
onSpace = {}
)
}
*/
fun EmojiGrid(
onClick: (EmojiItem) -> Unit,
onExit: () -> Unit,
onBackspace: () -> Unit,
onSpace: () -> Unit,
emojis: List<EmojiItem>,
keyboardShown: Boolean,
emojiMap: Map<String, EmojiItem>
) {
Column {
Emojis(
modifier = Modifier
.align(Alignment.CenterHorizontally)
.fillMaxWidth()
.weight(1.0f),
emojis = emojis,
onClick = onClick,
emojiMap = emojiMap
)
class PersistentEmojiState: PersistentActionState {
val bitmaps: BitmapRecycler = BitmapRecycler()
if (!keyboardShown) {
BottomRowKeyboardNavigation(
onExit = onExit,
onBackspace = onBackspace,
onSpace = onSpace
)
}
}
}
class PersistentEmojiState : PersistentActionState {
var emojis: MutableState<List<EmojiItem>?> = mutableStateOf(null)
var emojiMap: HashMap<String, EmojiItem> = HashMap()
suspend fun loadEmojis(context: Context) = withContext(Dispatchers.IO) {
val stream = context.resources.openRawResource(R.raw.gemoji)
@ -261,13 +353,20 @@ class PersistentEmojiState: PersistentActionState {
emoji = it.jsonObject["emoji"]!!.jsonPrimitive.content,
description = it.jsonObject["description"]!!.jsonPrimitive.content,
category = it.jsonObject["category"]!!.jsonPrimitive.content,
skinTones = it.jsonObject["skin_tones"]?.jsonPrimitive?.booleanOrNull ?: false
)
}
emojiMap = HashMap<String, EmojiItem>().apply {
emojis.value!!.forEach {
put(it.emoji, it)
}
}
}
}
override suspend fun cleanUp() {
bitmaps.freeAllBitmaps()
}
}
@ -279,7 +378,6 @@ val EmojiAction = Action(
simplePressImpl = null,
persistentState = { manager ->
val state = PersistentEmojiState()
state.bitmaps.getTextPaint(manager.getContext())
manager.getLifecycleScope().launch {
state.loadEmojis(manager.getContext())
}
@ -305,13 +403,32 @@ val EmojiAction = Action(
manager.sendCodePointEvent(Constants.CODE_SPACE)
}, onBackspace = {
manager.sendCodePointEvent(Constants.CODE_DELETE)
}, bitmaps = state.bitmaps, emojis = emojis, keyboardShown = keyboardShown)
}, emojis = emojis, keyboardShown = keyboardShown, emojiMap = state.emojiMap)
}
}
override fun close() {
state.bitmaps.freeAllBitmaps()
}
}
}
)
)
/*
@Preview(showBackground = true)
@Composable
fun EmojiGridPreview() {
EmojiGrid(
onBackspace = {},
onClick = {},
onExit = {},
onSpace = {},
emojis = listOf("😀", "😃", "😄", "😁", "😆", "😅", "😂", "🤣", "😊", "😇").map {
EmojiItem(emoji = it, description = "", category = "", skinTones = false)
},
keyboardShown = false,
emojiMap = hashMapOf()
)
}
*/

View File

@ -0,0 +1,8 @@
package org.futo.inputmethod.latin.uix.actions.emoji
data class EmojiItem(
val emoji: String,
val description: String,
val category: String,
val skinTones: Boolean
)

View File

@ -0,0 +1,187 @@
// Note: Mainly lifted from androidx.emoji2.emojipicker
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.futo.inputmethod.latin.uix.actions.emoji
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Rect
import android.os.Build
import android.text.Layout
import android.text.Spanned
import android.text.StaticLayout
import android.text.TextPaint
import android.util.AttributeSet
import android.util.TypedValue
import android.view.View
import androidx.annotation.RequiresApi
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.graphics.applyCanvas
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.futo.inputmethod.latin.LatinIME
import org.futo.inputmethod.latin.R
class EmojiView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
) :
View(context, attrs) {
companion object {
private const val EMOJI_DRAW_TEXT_SIZE_SP = 32
}
init {
background = AppCompatResources.getDrawable(context, R.drawable.ripple_emoji_view)
importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_YES
}
internal var willDrawVariantIndicator: Boolean = true
private val textPaint = TextPaint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG).apply {
textSize = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_SP,
EMOJI_DRAW_TEXT_SIZE_SP.toFloat(),
context.resources.displayMetrics
)
}
private val offscreenCanvasBitmap: Bitmap = with(textPaint.fontMetricsInt) {
val size = bottom - top
Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val size =
minOf(
MeasureSpec.getSize(widthMeasureSpec),
MeasureSpec.getSize(heightMeasureSpec)
) - context.resources.getDimensionPixelSize(R.dimen.emoji_picker_emoji_view_padding)
setMeasuredDimension(size, size)
}
override fun draw(canvas: Canvas) {
super.draw(canvas)
canvas.run {
save()
scale(
width.toFloat() / offscreenCanvasBitmap.width,
height.toFloat() / offscreenCanvasBitmap.height
)
drawBitmap(offscreenCanvasBitmap, 0f, 0f, null)
restore()
}
}
var emoji: EmojiItem? = null
set(value) {
field = value
offscreenCanvasBitmap.eraseColor(Color.TRANSPARENT)
post {
if (value != null) {
if (value == this.emoji) {
drawEmoji(
value.emoji,
drawVariantIndicator = willDrawVariantIndicator && value.skinTones
)
contentDescription = value.description
}
invalidate()
} else {
//offscreenCanvasBitmap.eraseColor(Color.TRANSPARENT)
}
}
}
private fun drawEmoji(emoji: CharSequence, drawVariantIndicator: Boolean) {
//offscreenCanvasBitmap.eraseColor(Color.TRANSPARENT)
offscreenCanvasBitmap.applyCanvas {
if (emoji is Spanned) {
createStaticLayout(emoji, width).draw(this)
} else {
val textWidth = textPaint.measureText(emoji, 0, emoji.length)
drawText(
emoji,
/* start = */ 0,
/* end = */ emoji.length,
/* x = */ (width - textWidth) / 2,
/* y = */ -textPaint.fontMetrics.top,
textPaint,
)
}
if (drawVariantIndicator) {
AppCompatResources.getDrawable(context, R.drawable.variant_availability_indicator)?.apply {
val canvasWidth = this@applyCanvas.width
val canvasHeight = this@applyCanvas.height
val indicatorWidth =
context.resources.getDimensionPixelSize(
R.dimen.variant_availability_indicator_width
)
val indicatorHeight =
context.resources.getDimensionPixelSize(
R.dimen.variant_availability_indicator_height
)
bounds = Rect(
canvasWidth - indicatorWidth,
canvasHeight - indicatorHeight,
canvasWidth,
canvasHeight
)
}!!.draw(this)
}
}
}
@RequiresApi(23)
internal object Api23Impl {
fun createStaticLayout(emoji: Spanned, textPaint: TextPaint, width: Int): StaticLayout =
StaticLayout.Builder.obtain(
emoji, 0, emoji.length, textPaint, width
).apply {
setAlignment(Layout.Alignment.ALIGN_CENTER)
setLineSpacing(/* spacingAdd = */ 0f, /* spacingMult = */ 1f)
setIncludePad(false)
}.build()
}
private fun createStaticLayout(emoji: Spanned, width: Int): StaticLayout {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
return Api23Impl.createStaticLayout(emoji, textPaint, width)
} else {
@Suppress("DEPRECATION")
return StaticLayout(
emoji,
textPaint,
width,
Layout.Alignment.ALIGN_CENTER,
/* spacingmult = */ 1f,
/* spacingadd = */ 0f,
/* includepad = */ false,
)
}
}
}