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
|
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()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
*/
|
@ -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…
x
Reference in New Issue
Block a user