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 package org.futo.inputmethod.latin.uix.actions
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Rect
import android.graphics.Color import android.view.ViewGroup
import android.graphics.Paint import android.view.accessibility.AccessibilityEvent
import android.text.TextPaint import androidx.annotation.UiThread
import android.util.TypedValue
import androidx.compose.foundation.Canvas 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.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
@ -28,29 +22,37 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.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.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview 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.dp
import androidx.compose.ui.unit.sp 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.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive 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.Action
import org.futo.inputmethod.latin.uix.ActionWindow import org.futo.inputmethod.latin.uix.ActionWindow
import org.futo.inputmethod.latin.uix.PersistentActionState 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 import kotlin.math.roundToInt
data class EmojiItem( data class PopupInfo(val emoji: EmojiItem, val x: Int, val y: Int)
val emoji: String,
val description: String,
val category: String
)
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( class EmojiViewHolder(
private val freeBitmaps: MutableList<Bitmap> = mutableListOf() context: Context,
) { width: Int,
var textPaint: TextPaint? = null height: Int
var total = 0 ) : RecyclerView.ViewHolder(EmojiView(context)) {
fun getTextPaint(context: Context): TextPaint { private val emojiView: EmojiView = (itemView as EmojiView).apply {
return textPaint ?: run { layoutParams = ViewGroup.LayoutParams(width, height)
this.textPaint = TextPaint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG).apply { isClickable = true
textSize = TypedValue.applyDimension( }
TypedValue.COMPLEX_UNIT_SP,
EMOJI_HEIGHT, fun bindEmoji(
context.resources.displayMetrics 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 { @UiThread
return freeBitmaps.removeFirstOrNull()?.apply { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EmojiViewHolder {
eraseColor(Color.TRANSPARENT) return EmojiViewHolder(parent.context, width = emojiCellWidth, height = emojiCellWidth)
} ?: 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)
}
} }
fun freeBitmap(bitmap: Bitmap) { override fun onBindViewHolder(holder: EmojiViewHolder, position: Int) {
if(freeBitmaps.size > 60) { val currentEmoji = emojiList[position]
println("Recycling bitmap, new total $total") holder.bindEmoji(currentEmoji, onClick, onSelectSkinTone)
total -= 1
bitmap.recycle()
} else {
freeBitmaps.add(bitmap)
}
} }
fun freeAllBitmaps() { override fun getItemCount() = emojiList.size
freeBitmaps.forEach {
println("Recycling bitmap due to freeAllBitmaps, new total $total")
total -= 1
it.recycle()
}
freeBitmaps.clear()
}
} }
@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) { val skinTones = listOf(
onDispose { "\ud83c\udffb",
bitmaps.freeBitmap(bitmap = offscreenCanvasBitmap) "\ud83c\udffc",
} "\ud83c\udffd",
} "\ud83c\udffe",
"\ud83c\udfff",
)
LaunchedEffect(emoji) { // TODO: Mixing multiple skin tones
withContext(Dispatchers.Unconfined) { // e.g. family: woman, woman, girl, girl: medium, dark. light, medium skin tones
val textPaint = bitmaps.textPaint!! fun generateSkinToneVariants(emoji: String, emojiMap: Map<String, EmojiItem>): List<String> {
yield() val humanEmojis = emoji.split("\u200D")
offscreenCanvasBitmap.applyCanvas { val variants = mutableListOf<String>()
yield()
val textWidth = textPaint.measureText(emoji, 0, emoji.length) for (modifier in skinTones) {
yield() val variant = humanEmojis.joinToString("\u200D") { part ->
drawText( if (emojiMap[part]?.category == "People & Body") {
emoji, part + modifier
/* start = */ 0, } else {
/* end = */ emoji.length, part
/* x = */ (width - textWidth) / 2,
/* y = */ -textPaint.fontMetrics.top,
textPaint,
)
yield()
} }
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( var activePopup: PopupInfo? by remember { mutableStateOf(null) }
bitmap = imageBitmap,
contentDescription = emoji, val emojiAdapter = remember {
modifier = Modifier.fillMaxSize() 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 @Composable
fun EmojiGrid(onClick: (EmojiItem) -> Unit, onExit: () -> Unit, onBackspace: () -> Unit, onSpace: () -> Unit, bitmaps: BitmapRecycler, emojis: List<EmojiItem>, keyboardShown: Boolean) { fun BottomRowKeyboardNavigation(onExit: () -> Unit, onBackspace: () -> Unit, onSpace: () -> Unit) {
val context = LocalContext.current Surface(
val spToDp = context.resources.displayMetrics.scaledDensity / context.resources.displayMetrics.density color = MaterialTheme.colorScheme.background, modifier = Modifier
.fillMaxWidth()
Column { .height(48.dp)
LazyVerticalGrid( ) {
columns = GridCells.Adaptive((40.sp * spToDp).value.dp), Row(modifier = Modifier.padding(2.dp, 8.dp, 2.dp, 0.dp)) {
contentPadding = PaddingValues(10.dp), IconButton(onClick = { onExit() }) {
modifier = Modifier.weight(1.0f) Text("ABC", fontSize = 14.sp)
) {
items(emojis, key = { it.emoji }) { emoji ->
Box(modifier = Modifier
.fillMaxSize()
.clickable {
onClick(emoji)
}) {
EmojiIcon(emoji.emoji, bitmaps)
}
} }
}
if(!keyboardShown) { Button(
Surface( onClick = { onSpace() }, modifier = Modifier
color = MaterialTheme.colorScheme.background, modifier = Modifier .weight(1.0f)
.fillMaxWidth() .padding(8.dp, 2.dp), colors = ButtonDefaults.buttonColors(
.height(48.dp) 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)) { Text("")
IconButton(onClick = { onExit() }) { }
Text("ABC", fontSize = 14.sp)
}
Button( IconButton(onClick = { onBackspace() }) {
onClick = { onSpace() }, modifier = Modifier val icon = painterResource(id = R.drawable.delete)
.weight(1.0f) val iconColor = MaterialTheme.colorScheme.onBackground
.padding(8.dp, 2.dp), colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.33f), Canvas(modifier = Modifier.fillMaxSize()) {
contentColor = MaterialTheme.colorScheme.onBackground, translate(
disabledContainerColor = MaterialTheme.colorScheme.outline, left = this.size.width / 2.0f - icon.intrinsicSize.width / 2.0f,
disabledContentColor = MaterialTheme.colorScheme.onBackground, top = this.size.height / 2.0f - icon.intrinsicSize.height / 2.0f
), shape = RoundedCornerShape(32.dp)
) { ) {
Text("") with(icon) {
} draw(
icon.intrinsicSize,
IconButton(onClick = { onBackspace() }) { colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(
val icon = painterResource(id = R.drawable.delete) iconColor
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
)
)
}
}
} }
} }
} }
@ -233,22 +307,40 @@ fun EmojiGrid(onClick: (EmojiItem) -> Unit, onExit: () -> Unit, onBackspace: ()
} }
} }
/*
@Preview(showBackground = true)
@Composable @Composable
fun EmojiGridPreview() { fun EmojiGrid(
EmojiGrid( onClick: (EmojiItem) -> Unit,
onBackspace = {}, onExit: () -> Unit,
onClick = {}, onBackspace: () -> Unit,
onExit = {}, onSpace: () -> Unit,
onSpace = {} 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 { if (!keyboardShown) {
val bitmaps: BitmapRecycler = BitmapRecycler() BottomRowKeyboardNavigation(
onExit = onExit,
onBackspace = onBackspace,
onSpace = onSpace
)
}
}
}
class PersistentEmojiState : PersistentActionState {
var emojis: MutableState<List<EmojiItem>?> = mutableStateOf(null) var emojis: MutableState<List<EmojiItem>?> = mutableStateOf(null)
var emojiMap: HashMap<String, EmojiItem> = HashMap()
suspend fun loadEmojis(context: Context) = withContext(Dispatchers.IO) { suspend fun loadEmojis(context: Context) = withContext(Dispatchers.IO) {
val stream = context.resources.openRawResource(R.raw.gemoji) val stream = context.resources.openRawResource(R.raw.gemoji)
@ -261,13 +353,20 @@ class PersistentEmojiState: PersistentActionState {
emoji = it.jsonObject["emoji"]!!.jsonPrimitive.content, emoji = it.jsonObject["emoji"]!!.jsonPrimitive.content,
description = it.jsonObject["description"]!!.jsonPrimitive.content, description = it.jsonObject["description"]!!.jsonPrimitive.content,
category = it.jsonObject["category"]!!.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() { override suspend fun cleanUp() {
bitmaps.freeAllBitmaps()
} }
} }
@ -279,7 +378,6 @@ val EmojiAction = Action(
simplePressImpl = null, simplePressImpl = null,
persistentState = { manager -> persistentState = { manager ->
val state = PersistentEmojiState() val state = PersistentEmojiState()
state.bitmaps.getTextPaint(manager.getContext())
manager.getLifecycleScope().launch { manager.getLifecycleScope().launch {
state.loadEmojis(manager.getContext()) state.loadEmojis(manager.getContext())
} }
@ -305,13 +403,32 @@ val EmojiAction = Action(
manager.sendCodePointEvent(Constants.CODE_SPACE) manager.sendCodePointEvent(Constants.CODE_SPACE)
}, onBackspace = { }, onBackspace = {
manager.sendCodePointEvent(Constants.CODE_DELETE) manager.sendCodePointEvent(Constants.CODE_DELETE)
}, bitmaps = state.bitmaps, emojis = emojis, keyboardShown = keyboardShown) }, emojis = emojis, keyboardShown = keyboardShown, emojiMap = state.emojiMap)
} }
} }
override fun close() { 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,
)
}
}
}