SubwayTooter-Android-App/app/src/main/java/jp/juggler/subwaytooter/dialog/EmojiPicker.kt

634 lines
22 KiB
Kotlin
Raw Normal View History

package jp.juggler.subwaytooter.dialog
import android.app.Dialog
2022-05-29 15:38:21 +02:00
import android.graphics.Rect
import android.graphics.drawable.PictureDrawable
2022-05-29 15:38:21 +02:00
import android.text.Editable
import android.text.TextWatcher
import android.util.TypedValue
2022-05-29 15:38:21 +02:00
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.widget.ImageButton
import android.widget.ImageView
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.AppCompatButton
import androidx.appcompat.widget.AppCompatImageView
import androidx.appcompat.widget.AppCompatTextView
2022-05-29 15:38:21 +02:00
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.ItemDecoration
import com.bumptech.glide.Glide
2022-05-29 15:38:21 +02:00
import com.google.android.flexbox.FlexboxLayout
import jp.juggler.subwaytooter.App1
import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.databinding.DlgPickerEmojiBinding
import jp.juggler.subwaytooter.emoji.*
import jp.juggler.subwaytooter.global.appPref
import jp.juggler.subwaytooter.pref.PrefB
import jp.juggler.subwaytooter.pref.PrefS
import jp.juggler.subwaytooter.pref.put
import jp.juggler.subwaytooter.table.SavedAccount
2022-05-29 15:38:21 +02:00
import jp.juggler.subwaytooter.util.minHeightCompat
import jp.juggler.subwaytooter.util.minWidthCompat
import jp.juggler.subwaytooter.view.NetworkEmojiView
2019-01-19 03:36:40 +01:00
import jp.juggler.util.*
2022-05-29 15:38:21 +02:00
private class EmojiPicker(
private val activity: AppCompatActivity,
private val accessInfo: SavedAccount?,
2022-05-29 15:38:21 +02:00
private val closeOnSelected: Boolean,
private val onPicked: (EmojiBase, bInstanceHasCustomEmoji: Boolean) -> Unit,
) {
companion object {
2022-05-29 15:38:21 +02:00
private val log = LogCategory("EmojiPicker")
2022-05-29 15:38:21 +02:00
private const val VT_CATEGORY = 0
private const val VT_CUSTOM_EMOJI = 1
private const val VT_TWEMOJI = 2
private const val VT_COMPAT_EMOJI = 3
private const val VT_SPACE = 4
2022-05-29 15:38:21 +02:00
private const val gridCols = 6
2022-05-29 15:38:21 +02:00
private fun EmojiCategory.getUnicodeEmojis() =
emojiList.map { PickerItemUnicode(unicodeEmoji = it) }
}
2022-05-29 15:38:21 +02:00
private class SkinTone(val codeInt: Int) {
val code = StringBuilder().apply { appendCodePoint(codeInt) }.toString()
}
2022-05-29 15:38:21 +02:00
private sealed interface PickerItem
2022-05-29 15:38:21 +02:00
private class PickerItemUnicode(val unicodeEmoji: UnicodeEmoji) : PickerItem
private class PickerItemCustom(val customEmoji: CustomEmoji) : PickerItem
2022-05-29 15:38:21 +02:00
private object PickerItemSpace : PickerItem
2022-05-29 15:38:21 +02:00
private class PickerItemCategory(
var name: String,
val category: EmojiCategory? = null,
) : PickerItem {
val items = ArrayList<PickerItem>()
2022-05-29 15:38:21 +02:00
fun createFiltered(keywordLower: String?) =
PickerItemCategory(name = name, category = category).also { dst ->
dst.items.addAll(
if (keywordLower.isNullOrEmpty()) {
items
} else {
items.filter {
when (it) {
is PickerItemCustom ->
it.customEmoji.shortcode.contains(keywordLower)
is PickerItemUnicode ->
it.unicodeEmoji.namesLower.any { n -> n.contains(keywordLower) }
else -> false
}
}
}
2022-05-29 15:38:21 +02:00
)
}
2022-05-29 15:38:21 +02:00
}
2022-05-29 15:38:21 +02:00
private val views = DlgPickerEmojiBinding.inflate(activity.layoutInflater)
private lateinit var pickerCategries: List<PickerItemCategory>
private val adapter = GridAdapter()
private val ibSkinTone = listOf(
Pair(R.id.btnSkinTone0, 0),
Pair(R.id.btnSkinTone1, 0x1F3FB),
Pair(R.id.btnSkinTone2, 0x1F3FC),
Pair(R.id.btnSkinTone3, 0x1F3FD),
Pair(R.id.btnSkinTone4, 0x1F3FE),
Pair(R.id.btnSkinTone5, 0x1F3FF),
).map { (btnId, skinToneCode) ->
views.root.findViewById<ImageButton>(btnId).apply {
tag = SkinTone(skinToneCode)
setOnClickListener {
selectedTone = (it.tag as SkinTone)
showSkinTone()
@Suppress("NotifyDataSetChanged")
adapter.notifyDataSetChanged()
}
}
2022-05-29 15:38:21 +02:00
}
2021-02-15 08:45:22 +01:00
2022-05-29 15:38:21 +02:00
private val gridSize = (0.5f + 48f * activity.resources.displayMetrics.density).toInt()
private val matchParent = RecyclerView.LayoutParams.MATCH_PARENT
2022-05-29 15:38:21 +02:00
private val useTwemoji = PrefB.bpUseTwemoji()
private val disableAnimation = PrefB.bpDisableEmojiAnimation()
2022-05-29 15:38:21 +02:00
private var selectedTone: SkinTone = (ibSkinTone[0].tag as SkinTone)
2022-05-29 15:38:21 +02:00
private lateinit var dialog: Dialog
private var bInstanceHasCustomEmoji = false
2022-05-29 15:38:21 +02:00
private val textWatcher = object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
override fun afterTextChanged(s: Editable?) {
showFiltered(null, s?.toString())
}
2022-05-29 15:38:21 +02:00
}
2022-05-29 15:38:21 +02:00
private fun updateRecent(
targetName: String,
targetInstance: String?,
) {
// Recentをロード(他インスタンスの絵文字を含む)
val list = try {
PrefS.spEmojiPickerRecent().decodeJsonArray().objectList()
} catch (_: Throwable) {
emptyList()
}.toMutableList()
2022-05-29 15:38:21 +02:00
// 選択された絵文字と同じ項目を除去
// 項目が増えすぎたら減らす
// ユニコード絵文字256個、カスタム絵文字はインスタンス別256個まで
var nCount = 0
val it = list.iterator()
while (it.hasNext()) {
val item = it.next()
val itemInstance = item.string("instance")
val itemName = item.string("name")
if (itemInstance == targetInstance) {
if (itemName == targetName || ++nCount >= 256) {
it.remove()
}
2022-05-29 15:38:21 +02:00
}
}
2022-05-29 15:38:21 +02:00
// 先頭に項目を追加
list.add(0, JsonObject().apply {
put("name", targetName)
targetInstance?.let { put("instance", it) }
})
2022-05-29 15:38:21 +02:00
// 保存する
try {
2022-05-29 15:38:21 +02:00
val sv = list.toJsonArray().toString()
appPref.edit().put(PrefS.spEmojiPickerRecent, sv).apply()
} catch (ex: Throwable) {
2022-05-29 15:38:21 +02:00
log.e(ex)
}
}
2022-05-29 15:38:21 +02:00
private fun setItemClick(view: View) {
view.setOnClickListener {
val targetEmoji: EmojiBase
val targetName: String
val targetInstance: String?
when (val item = it.getTag(R.id.btnAbout)) {
is PickerItemUnicode -> {
targetEmoji = applySkinTone(item.unicodeEmoji)
targetName = targetEmoji.unifiedName
targetInstance = null
}
is PickerItemCustom -> {
targetEmoji = item.customEmoji
targetName = accessInfo!!.apiHost.ascii
targetInstance = null
}
else -> return@setOnClickListener
}
2022-05-29 15:38:21 +02:00
if (closeOnSelected) dialog.dismissSafe()
2022-05-29 15:38:21 +02:00
updateRecent(targetName, targetInstance)
2022-05-29 15:38:21 +02:00
onPicked(targetEmoji, bInstanceHasCustomEmoji)
}
}
2022-05-29 15:38:21 +02:00
private sealed class ViewHolderBase(view: View) : RecyclerView.ViewHolder(view) {
abstract fun bind(item: PickerItem)
}
2022-05-29 15:38:21 +02:00
private inner class VhCategory(
val view: AppCompatTextView = AppCompatTextView(activity),
) : ViewHolderBase(view) {
init {
view.layoutParams = RecyclerView.LayoutParams(matchParent, gridSize)
view.gravity = Gravity.START or Gravity.CENTER_VERTICAL
view.includeFontPadding = false
}
2022-05-29 15:38:21 +02:00
override fun bind(item: PickerItem) {
if (item is PickerItemCategory) {
view.text = item.name
}
}
2022-05-29 15:38:21 +02:00
}
2022-05-29 15:38:21 +02:00
private inner class VhSpace(
view: View = View(activity),
) : ViewHolderBase(view) {
init {
view.layoutParams = RecyclerView.LayoutParams(matchParent, gridSize)
}
2022-05-29 15:38:21 +02:00
override fun bind(item: PickerItem) {
}
2022-05-29 15:38:21 +02:00
}
2022-05-29 15:38:21 +02:00
private inner class VhCustomEmoji(
val view: NetworkEmojiView = NetworkEmojiView(activity),
) : ViewHolderBase(view) {
2022-05-29 15:38:21 +02:00
init {
setItemClick(view)
view.layoutParams = RecyclerView.LayoutParams(matchParent, gridSize)
}
2022-05-29 15:38:21 +02:00
override fun bind(item: PickerItem) {
if (activity.isDestroyed) return
if (item is PickerItemCustom) {
view.setTag(R.id.btnAbout, item)
view.setEmoji(
if (disableAnimation) {
item.customEmoji.staticUrl
} else {
item.customEmoji.url
}
)
}
}
2022-05-29 15:38:21 +02:00
}
2022-05-29 15:38:21 +02:00
private inner class VhTwemoji(
val view: AppCompatImageView = AppCompatImageView(activity),
) : ViewHolderBase(view) {
init {
setItemClick(view)
view.layoutParams = RecyclerView.LayoutParams(matchParent, gridSize)
view.scaleType = ImageView.ScaleType.FIT_CENTER
}
2022-05-29 15:38:21 +02:00
override fun bind(item: PickerItem) {
if (activity.isDestroyed) return
if (item is PickerItemUnicode) {
view.setTag(R.id.btnAbout, item)
val emoji = applySkinTone(item.unicodeEmoji)
if (emoji.isSvg) {
Glide.with(activity)
.`as`(PictureDrawable::class.java)
.load("file:///android_asset/${emoji.assetsName}")
.into(view)
} else {
Glide.with(activity)
.load(emoji.drawableId)
.into(view)
}
}
}
2022-05-29 15:38:21 +02:00
}
2022-05-29 15:38:21 +02:00
private inner class VhAppCompatEmoji(
val view: AppCompatTextView = AppCompatTextView(activity),
) : ViewHolderBase(view) {
init {
setItemClick(view)
view.layoutParams = RecyclerView.LayoutParams(matchParent, gridSize)
view.gravity = Gravity.CENTER
view.setLineSpacing(0f, 0f)
view.setTextSize(TypedValue.COMPLEX_UNIT_PX, gridSize.toFloat() * 0.7f)
view.includeFontPadding = false
}
2022-05-29 15:38:21 +02:00
override fun bind(item: PickerItem) {
if (activity.isDestroyed) return
if (item is PickerItemUnicode) {
view.setTag(R.id.btnAbout, item)
2022-05-29 15:38:21 +02:00
val unicodeEmoji = applySkinTone(item.unicodeEmoji)
view.text = unicodeEmoji.unifiedCode
}
}
2022-05-29 15:38:21 +02:00
}
2022-05-29 15:38:21 +02:00
class GridDecoration(private val space: Int) : ItemDecoration() {
override fun getItemOffsets(
outRect: Rect,
view: View,
2022-05-29 15:38:21 +02:00
parent: RecyclerView,
state: RecyclerView.State,
) {
2022-05-29 15:38:21 +02:00
outRect.left = space
outRect.right = space
outRect.bottom = space
// Add top margin only for the first item to avoid double space between items
outRect.top = if (parent.getChildLayoutPosition(view) == 0) {
space
} else {
0
}
}
}
2022-05-29 15:38:21 +02:00
private inner class GridAdapter : RecyclerView.Adapter<ViewHolderBase>() {
2022-05-29 15:38:21 +02:00
var list: List<PickerItem> = emptyList()
set(value) {
field = value
@Suppress("NotifyDataSetChanged")
notifyDataSetChanged()
}
2022-05-29 15:38:21 +02:00
val spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int) =
when {
list[position] is PickerItemCategory -> gridCols
else -> 1
}
}
2022-05-29 15:38:21 +02:00
override fun getItemCount() = list.size
2022-05-29 15:38:21 +02:00
override fun getItemViewType(position: Int) =
when (list[position]) {
is PickerItemSpace -> VT_SPACE
is PickerItemCategory -> VT_CATEGORY
is PickerItemCustom -> VT_CUSTOM_EMOJI
is PickerItemUnicode -> when {
useTwemoji -> VT_TWEMOJI
else -> VT_COMPAT_EMOJI
}
}
2022-05-29 15:38:21 +02:00
override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int) =
when (viewType) {
VT_CATEGORY -> VhCategory()
VT_CUSTOM_EMOJI -> VhCustomEmoji()
VT_TWEMOJI -> VhTwemoji()
VT_COMPAT_EMOJI -> VhAppCompatEmoji()
VT_SPACE -> VhSpace()
else -> error("unknown viewType=$viewType")
}
2022-05-29 15:38:21 +02:00
override fun onBindViewHolder(viewHolder: ViewHolderBase, position: Int) {
viewHolder.bind(list[position])
}
2022-05-29 15:38:21 +02:00
}
2022-05-29 15:38:21 +02:00
// 最近使用した絵文字のPickerCategoryを作る
private fun createRecentsCategory() =
PickerItemCategory(
name = activity.getString(R.string.emoji_category_recent),
category = EmojiCategory.Recent,
).apply {
val customEmojiMap = accessInfo?.let { App1.custom_emoji_lister.getMapNonBlocking(it) }
for (item in PrefS.spEmojiPickerRecent().decodeJsonArray().objectList()) {
val name = item.string("name")
val instance = item.string("instance")
try {
name ?: error("missing emoji name")
if (instance == null) {
EmojiMap.shortNameMap[name]?.let {
items.add(PickerItemUnicode(unicodeEmoji = it))
}
} else if (instance == accessInfo?.apiHost?.ascii) {
customEmojiMap?.get(name)?.let {
items.add(PickerItemCustom(customEmoji = it))
}
}
2022-05-29 15:38:21 +02:00
} catch (ex: Throwable) {
log.w(ex, "can't add emoji. $name, $instance")
}
}
}.takeIf { it.items.isNotEmpty() }
private suspend fun createCustomEmojiCategories(): List<PickerItemCategory> {
accessInfo ?: error("missing accessInfo")
val context = activity
val srcList = App1.custom_emoji_lister.getList(accessInfo)
val nameMap = HashMap<String, PickerItemCategory>()
for (emoji in srcList) {
if (!emoji.visibleInPicker) continue
val categoryName = emoji.category ?: ""
(nameMap[categoryName]
?: PickerItemCategory(
name = categoryName,
category = EmojiCategory.Custom,
).also { nameMap[categoryName] = it })
.items.add(PickerItemCustom(emoji))
}
val otherCategory = nameMap[""]
// カテゴリ名の頭に「カスタム」を追加
return nameMap.values.onEach {
it.name = when (it) {
otherCategory -> when (nameMap.size) {
0 -> context.getString(R.string.emoji_category_custom)
else -> context.getString(
R.string.emoji_picker_custom_of,
context.getString(R.string.others)
)
}
2022-05-29 15:38:21 +02:00
else ->
context.getString(R.string.emoji_picker_custom_of, it.name)
}
2022-05-29 15:38:21 +02:00
}.sortedWith { l, r ->
if (l == otherCategory) 1
else if (r == otherCategory) -1
else l.name.compareTo(r.name)
}
2022-05-29 15:38:21 +02:00
}
2022-05-29 15:38:21 +02:00
private suspend fun buildCategoryList() = buildList {
// 最近使った絵文字
try {
createRecentsCategory()?.let { add(it) }
} catch (ex: Throwable) {
log.w(ex)
}
2022-05-29 15:38:21 +02:00
// カスタム絵文字
try {
2022-05-29 15:38:21 +02:00
addAll(createCustomEmojiCategories())
} catch (ex: Throwable) {
log.w(ex)
}
2022-05-29 15:38:21 +02:00
arrayOf(
EmojiCategory.People,
EmojiCategory.ComplexTones,
EmojiCategory.Nature,
EmojiCategory.Foods,
EmojiCategory.Activities,
EmojiCategory.Places,
EmojiCategory.Objects,
EmojiCategory.Symbols,
EmojiCategory.Flags,
).forEach { category ->
val pc = PickerItemCategory(
category = category,
name = activity.getString(category.titleId),
)
pc.items.addAll(category.getUnicodeEmojis())
add(pc)
}
if (PrefB.bpEmojiPickerCategoryOther(activity)) {
val category = EmojiCategory.Others
val pc = PickerItemCategory(
category = category,
name = activity.getString(category.titleId),
)
pc.items.addAll(category.getUnicodeEmojis())
add(pc)
}
}
2022-05-29 15:38:21 +02:00
private fun applySkinTone(emojiArg: UnicodeEmoji): UnicodeEmoji {
// トーン指定がないなら元のコード
val selectedTone = selectedTone.takeIf { it.codeInt > 0 } ?: return emojiArg
2022-05-29 15:38:21 +02:00
var emoji = emojiArg
2022-05-29 15:38:21 +02:00
// Recentなどでは既にsuffixがついた名前が用意されている
// suffixを除去する
emoji.toneParent?.let { emoji = it }
2022-05-29 15:38:21 +02:00
// 指定したトーンのサフィックスを追加して、絵文字が存在すればその名前にする
emoji.toneChildren.find { it.first == selectedTone.code }
?.let { return it.second }
2022-05-29 15:38:21 +02:00
// なければトーンなしの絵文字
return emoji
}
2022-05-29 15:38:21 +02:00
private fun showSkinTone() {
val selectedTone = selectedTone
ibSkinTone.forEach {
if (selectedTone == it.tag) {
it.setImageResource(R.drawable.check_mark)
} else {
it.setImageDrawable(null)
}
}
2022-05-29 15:38:21 +02:00
}
2022-05-29 15:38:21 +02:00
private fun showFiltered(
selectedCategory: EmojiCategory?,
selectedKeyword: String?,
) {
adapter.list = buildList {
val keywordLower = selectedKeyword?.lowercase()?.trim()
pickerCategries.filter {
if (selectedCategory == null) {
true
} else {
it.category == selectedCategory
}
}.mapNotNull { category ->
category.createFiltered(keywordLower)
.takeIf { it.items.isNotEmpty() }
}.forEach {
add(it)
addAll(it.items)
val mod = it.items.size % gridCols
if (mod > 0) {
repeat(gridCols - mod) {
add(PickerItemSpace)
}
}
}
}
2022-05-29 15:38:21 +02:00
}
2022-05-29 15:38:21 +02:00
suspend fun start() {
pickerCategries = buildCategoryList()
bInstanceHasCustomEmoji = pickerCategries.any { it.category == EmojiCategory.Custom }
val wrapContent = FlexboxLayout.LayoutParams.WRAP_CONTENT
val density = activity.resources.displayMetrics.density
val minWidth = (density * 48f + 0.5f).toInt()
val padTb = (density * 4f + 0.5f).toInt()
val padLr = (density * 6f + 0.5f).toInt()
arrayOf(
null,
EmojiCategory.Recent,
EmojiCategory.Custom,
EmojiCategory.People,
EmojiCategory.ComplexTones,
EmojiCategory.Nature,
EmojiCategory.Foods,
EmojiCategory.Activities,
EmojiCategory.Places,
EmojiCategory.Objects,
EmojiCategory.Symbols,
EmojiCategory.Flags,
EmojiCategory.Others,
).forEach {
AppCompatButton(activity).apply {
layoutParams = FlexboxLayout.LayoutParams(wrapContent, wrapContent)
background =
ContextCompat.getDrawable(context, R.drawable.btn_bg_transparent_round6dp)
minWidthCompat = minWidth
minHeightCompat = minWidth
setPadding(padLr, padTb, padLr, padTb)
text = activity?.getString(it?.titleId ?: R.string.all)
setOnClickListener { _ ->
views.etFilter.removeTextChangedListener(textWatcher)
views.etFilter.setText("")
views.etFilter.addTextChangedListener(textWatcher)
showFiltered(it, null)
}
}.let { views.llCategories.addView(it) }
}
2022-05-29 15:38:21 +02:00
views.etFilter.addTextChangedListener(textWatcher)
2022-05-29 15:38:21 +02:00
showFiltered(null, null)
2022-05-29 15:38:21 +02:00
views.rvGrid.adapter = adapter
views.rvGrid.layoutManager = GridLayoutManager(
activity,
gridCols,
RecyclerView.VERTICAL,
false
).also {
it.spanSizeLookup = adapter.spanSizeLookup
}
2022-05-29 15:38:21 +02:00
val cellSpacing = (density * 1f + 0.5f).toInt()
views.rvGrid.addItemDecoration(GridDecoration(cellSpacing))
showSkinTone()
this.dialog = Dialog(activity)
dialog.setContentView(views.root)
dialog.setCancelable(true)
dialog.setCanceledOnTouchOutside(true)
val w = dialog.window
// XXX Android 11 で SOFT_INPUT_ADJUST_RESIZE はdeprecatedになった
@Suppress("DEPRECATION")
w?.setSoftInputMode(
WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE or
WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN
)
dialog.show()
}
}
2022-05-29 15:38:21 +02:00
fun launchEmojiPicker(
activity: AppCompatActivity,
accessInfo: SavedAccount?,
closeOnSelected: Boolean,
onPicked: (EmojiBase, bInstanceHasCustomEmoji: Boolean) -> Unit,
) = activity.launchAndShowError {
EmojiPicker(
activity = activity,
accessInfo = accessInfo,
closeOnSelected = closeOnSelected,
onPicked = onPicked,
).start()
}