Skip to content

Commit 4aa3ad6

Browse files
author
guttermonk
committed
feat(compose): Add a scrollable, themed emoji grid picker for custom reactions
The reaction bar's '+' now opens an androidx EmojiPickerView (categorised, scrollable, tracks its own recents) instead of a keyboard text field. Details: - bump com.google.android.material 1.0.0 -> 1.6.1 and add emoji2-emojipicker, so the picker can use a Material3 theme that follows the app's day/night setting - host it in a full-screen translucent dialog with a dim scrim and the drawer pinned to the bottom, giving the grid a bounded viewport that scrolls; a swipe starting above the drawer dismisses it (even if it travels into the drawer) while swipes within scroll - copy the activity's system-bar colours onto the dialog so they don't change while the drawer is open - guard isEmojiOnly against EmojiCompat not being loaded yet (a startup crash surfaced by the new dependency's init timing)
1 parent 90b8cf5 commit 4aa3ad6

5 files changed

Lines changed: 95 additions & 42 deletions

File tree

build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ buildscript {
2020
ext.junit_version = '4.12'
2121
ext.kotlin_version = '1.7.21'
2222
ext.lifecycle_version = '2.1.0'
23-
ext.material_version = '1.0.0'
23+
ext.material_version = '1.6.1'
2424
ext.mockito_version = '2.18.3'
2525
ext.moshi_version = '1.8.0'
2626
ext.okhttp3_version = '4.10.0'

presentation/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ dependencies {
120120
// androidx
121121
implementation "androidx.appcompat:appcompat:$androidx_appcompat_version"
122122
implementation "androidx.emoji2:emoji2-bundled:$androidx_emoji2_version"
123+
implementation "androidx.emoji2:emoji2-emojipicker:$androidx_emoji2_version"
123124
implementation "androidx.constraintlayout:constraintlayout:$androidx_constraintlayout_version"
124125
implementation "androidx.core:core-ktx:$androidx_core_version"
125126
implementation "androidx.viewpager2:viewpager2:$androidx_viewpager_version"

presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeActivity.kt

Lines changed: 62 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import android.Manifest
2222
import android.animation.LayoutTransition
2323
import android.app.Activity
2424
import android.app.DatePickerDialog
25+
import android.app.Dialog
2526
import android.app.TimePickerDialog
2627
import android.content.ActivityNotFoundException
2728
import android.content.ContentValues
@@ -36,8 +37,6 @@ import android.provider.ContactsContract
3637
import android.provider.MediaStore
3738
import android.speech.RecognizerIntent
3839
import android.speech.SpeechRecognizer
39-
import android.text.Editable
40-
import android.text.TextWatcher
4140
import android.text.format.DateFormat
4241
import android.view.ContextMenu
4342
import android.view.DragEvent.ACTION_DRAG_ENDED
@@ -46,9 +45,10 @@ import android.view.DragEvent.ACTION_DROP
4645
import android.view.Gravity
4746
import android.view.Menu
4847
import android.view.MenuItem
48+
import android.view.MotionEvent
4949
import android.view.View
50-
import android.view.WindowManager
51-
import android.widget.EditText
50+
import android.view.ViewGroup
51+
import android.widget.FrameLayout
5252
import android.widget.LinearLayout
5353
import android.widget.PopupWindow
5454
import android.widget.SeekBar
@@ -58,7 +58,9 @@ import androidx.appcompat.app.AlertDialog
5858
import androidx.constraintlayout.widget.ConstraintSet
5959
import androidx.core.app.ActivityCompat
6060
import androidx.core.content.ContextCompat
61+
import androidx.core.view.WindowInsetsControllerCompat
6162
import androidx.core.view.isVisible
63+
import androidx.emoji2.emojipicker.EmojiPickerView
6264
import androidx.lifecycle.ViewModelProvider
6365
import androidx.lifecycle.ViewModelProviders
6466
import com.google.android.flexbox.FlexboxLayout
@@ -101,7 +103,6 @@ import io.reactivex.schedulers.Schedulers
101103
import dev.octoshrimpy.quik.databinding.ComposeActivityBinding
102104
import io.reactivex.subjects.PublishSubject
103105
import io.reactivex.subjects.Subject
104-
import java.text.BreakIterator
105106
import java.text.SimpleDateFormat
106107
import java.util.Calendar
107108
import java.util.Date
@@ -873,46 +874,67 @@ class ComposeActivity : QkThemedActivity(), ComposeView {
873874
}
874875

875876
private fun promptForCustomEmoji(messageId: Long) {
876-
val input = EditText(this).apply {
877-
hint = getString(R.string.compose_reaction_emoji_hint)
878-
setSingleLine()
877+
// Show a full emoji grid picker (categorised, tracks its own recents) rather than relying on
878+
// the system keyboard's emoji tab, which can't be opened programmatically. EmojiPickerView
879+
// needs a Material3 theme for its colours, and a plain fixed-height dialog (rather than a
880+
// bottom sheet) gives its internal grid an unambiguous bounded viewport so it scrolls.
881+
val dialog = Dialog(this, R.style.ReactionEmojiPickerTheme)
882+
val drawerHeight = (resources.displayMetrics.heightPixels * 0.7).toInt()
883+
884+
val picker = EmojiPickerView(dialog.context).apply {
885+
// opaque surface background since the window itself is transparent
886+
setBackgroundColor(dialog.context.resolveThemeColor(com.google.android.material.R.attr.colorSurface))
887+
setOnEmojiPickedListener { item ->
888+
reactionSelectedIntent.onNext(messageId to item.emoji)
889+
rememberRecentEmoji(item.emoji)
890+
dialog.dismiss()
891+
}
879892
}
880893

881-
val dialog = AlertDialog.Builder(this)
882-
.setTitle(R.string.compose_reaction_custom_title)
883-
.setView(input)
884-
.setNegativeButton(R.string.button_cancel, null)
885-
.create()
886-
887-
// pop the keyboard automatically so the user can type an emoji straight away
888-
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
889-
890-
// submit automatically as soon as an emoji is typed, then dismiss
891-
input.addTextChangedListener(object : TextWatcher {
892-
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
893-
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
894-
override fun afterTextChanged(s: Editable?) {
895-
val emoji = firstEmoji(s?.toString() ?: "") ?: return
896-
input.removeTextChangedListener(this)
897-
reactionSelectedIntent.onNext(messageId to emoji)
898-
rememberRecentEmoji(emoji)
899-
dialog.dismiss()
894+
// Full-screen root: a dim scrim with the drawer pinned to the bottom. A gesture that begins
895+
// above the drawer is captured here (so it dismisses even if it travels down into the
896+
// drawer), while a gesture that begins inside the drawer falls through to the grid so it
897+
// scrolls.
898+
val root = object : FrameLayout(dialog.context) {
899+
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean =
900+
ev.actionMasked == MotionEvent.ACTION_DOWN && ev.y < picker.top
901+
902+
override fun onTouchEvent(ev: MotionEvent): Boolean {
903+
if (ev.actionMasked == MotionEvent.ACTION_UP) dialog.dismiss()
904+
return true
900905
}
901-
})
906+
}.apply {
907+
setBackgroundColor(0x80000000.toInt())
908+
addView(
909+
picker,
910+
FrameLayout.LayoutParams(
911+
FrameLayout.LayoutParams.MATCH_PARENT, drawerHeight, Gravity.BOTTOM
912+
)
913+
)
914+
}
902915

916+
dialog.setContentView(
917+
root,
918+
ViewGroup.LayoutParams(
919+
ViewGroup.LayoutParams.MATCH_PARENT,
920+
ViewGroup.LayoutParams.MATCH_PARENT
921+
)
922+
)
923+
dialog.window?.let { dialogWindow ->
924+
dialogWindow.setLayout(
925+
ViewGroup.LayoutParams.MATCH_PARENT,
926+
ViewGroup.LayoutParams.MATCH_PARENT
927+
)
928+
// The full-screen dialog window would otherwise recolour the system bars; copy the
929+
// activity's bar colours and light/dark icon appearance so they look unchanged.
930+
dialogWindow.statusBarColor = window.statusBarColor
931+
dialogWindow.navigationBarColor = window.navigationBarColor
932+
val activityBars = WindowInsetsControllerCompat(window, window.decorView)
933+
val dialogBars = WindowInsetsControllerCompat(dialogWindow, dialogWindow.decorView)
934+
dialogBars.isAppearanceLightStatusBars = activityBars.isAppearanceLightStatusBars
935+
dialogBars.isAppearanceLightNavigationBars = activityBars.isAppearanceLightNavigationBars
936+
}
903937
dialog.show()
904-
input.requestFocus()
905-
}
906-
907-
/** Returns the first grapheme cluster (which handles multi-codepoint emoji) of [text], or null */
908-
private fun firstEmoji(text: String): String? {
909-
val trimmed = text.trim()
910-
if (trimmed.isEmpty()) return null
911-
912-
val iterator = BreakIterator.getCharacterInstance()
913-
iterator.setText(trimmed)
914-
val end = iterator.next()
915-
return if (end == BreakIterator.DONE) trimmed else trimmed.substring(0, end)
916938
}
917939

918940
override fun onCreateOptionsMenu(menu: Menu?): Boolean {

presentation/src/main/java/com/moez/QKSMS/feature/extensions/CharSequenceExtensions.kt

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,18 @@ fun CharSequence.isEmojiOnly(considerWhitespace: Boolean = false): Boolean {
1414
if (cs.isEmpty())
1515
return false
1616

17-
return when (val spannable = EmojiCompat.get().process(
17+
// EmojiCompat loads its font asynchronously; calling process() before it has finished loading
18+
// throws "Not initialized yet". Until it's ready, treat the text as not emoji-only (it'll be
19+
// re-evaluated once the view rebinds after loading completes).
20+
val emojiCompat = try {
21+
EmojiCompat.get()
22+
} catch (e: IllegalStateException) {
23+
return false
24+
}
25+
if (emojiCompat.loadState != EmojiCompat.LOAD_STATE_SUCCEEDED)
26+
return false
27+
28+
return when (val spannable = emojiCompat.process(
1829
cs,
1930
0,
2031
(cs.length - 1),

presentation/src/main/res/values/themes.xml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,4 +84,23 @@
8484
<style name="AppThemeDialog.Black" />
8585
<style name="PopupTheme.Black" />
8686

87+
<!--
88+
Material3 (floating) dialog theme used only for the emoji reaction picker. EmojiPickerView
89+
reads Material3 colour attributes, which the app's AppCompat themes don't provide. DayNight
90+
follows the app's night-mode setting via the -night resource qualifier. A floating dialog
91+
theme lets us pin the window to a fixed height so the emoji grid gets a bounded, scrollable
92+
viewport.
93+
-->
94+
<style name="ReactionEmojiPickerTheme" parent="Theme.Material3.DayNight">
95+
<!-- Full-screen translucent window: the emoji drawer is pinned to the bottom over a dim
96+
scrim we draw ourselves, so a swipe that starts above the drawer and travels into it
97+
still dismisses (the whole gesture stays in one view hierarchy). The base DayNight
98+
theme keeps EmojiPickerView following the app's night mode. -->
99+
<item name="android:windowIsTranslucent">true</item>
100+
<item name="android:windowBackground">@android:color/transparent</item>
101+
<item name="android:backgroundDimEnabled">false</item>
102+
<item name="android:windowNoTitle">true</item>
103+
<item name="android:windowCloseOnTouchOutside">true</item>
104+
</style>
105+
87106
</resources>

0 commit comments

Comments
 (0)