mirror of
https://gitlab.futo.org/keyboard/latinime.git
synced 2024-09-28 14:54:30 +01:00
Rewrite EmojiAction
* Use RecyclerView for significantly better performance * Skin tone selector popup
This commit is contained in:
parent
2188a2b03a
commit
4d5359fb59
25
java/res/drawable/ripple_emoji_view.xml
Normal file
25
java/res/drawable/ripple_emoji_view.xml
Normal 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>
|
26
java/res/drawable/variant_availability_indicator.xml
Normal file
26
java/res/drawable/variant_availability_indicator.xml
Normal 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>
|
38
java/res/values/emoji_view_dimens.xml
Normal file
38
java/res/values/emoji_view_dimens.xml
Normal 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>
|
@ -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()
|
||||
)
|
||||
}
|
||||
*/
|
@ -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
|
||||
)
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user