Tab actions UI in expanded candidate window

This commit is contained in:
Rocka 2026-05-31 15:16:17 +08:00
parent 873c08255c
commit e205195f38
No known key found for this signature in database
GPG Key ID: 28031158FFDD6853
6 changed files with 326 additions and 5 deletions

View File

@ -0,0 +1,150 @@
/*
* SPDX-License-Identifier: LGPL-2.1-or-later
* SPDX-FileCopyrightText: Copyright 2026 Fcitx5 for Android Contributors
*/
package org.fcitx.fcitx5.android.input.candidates.expanded
import android.annotation.SuppressLint
import android.content.Context
import android.content.res.ColorStateList
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
import android.graphics.drawable.LayerDrawable
import android.graphics.drawable.ShapeDrawable
import android.graphics.drawable.StateListDrawable
import android.graphics.drawable.shapes.RectShape
import android.view.Gravity
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import org.fcitx.fcitx5.android.core.CandidateAction
import org.fcitx.fcitx5.android.data.theme.Theme
import org.fcitx.fcitx5.android.input.AutoScaleTextView
import org.fcitx.fcitx5.android.input.keyboard.CustomGestureView
import org.fcitx.fcitx5.android.utils.pressHighlightDrawable
import splitties.dimensions.dp
import splitties.views.dsl.core.Ui
import splitties.views.dsl.core.add
import splitties.views.dsl.core.lParams
import splitties.views.dsl.core.matchParent
import splitties.views.dsl.core.view
import splitties.views.gravityCenter
abstract class CandidateTabActionsAdapter(val theme: Theme, val indicatorStyle: Boolean = false) :
RecyclerView.Adapter<CandidateTabActionsAdapter.TabActionViewHolder>() {
private var tabs = listOf<CandidateAction>()
@SuppressLint("NotifyDataSetChanged")
fun updateTabs(newTabs: List<CandidateAction>) {
tabs = newTabs
notifyDataSetChanged()
}
abstract fun onTriggerTabAction(id: Int)
override fun getItemCount() = tabs.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TabActionViewHolder {
val ui = TabActionUi(parent.context, theme, indicatorStyle)
ui.root.layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT
)
return TabActionViewHolder(ui)
}
override fun onBindViewHolder(holder: TabActionViewHolder, position: Int) {
val tab = tabs.getOrNull(position) ?: return
holder.update(tab)
}
override fun onViewRecycled(holder: TabActionViewHolder) {
holder.clear()
}
inner class TabActionViewHolder(val ui: TabActionUi) : RecyclerView.ViewHolder(ui.root) {
var tabAction: CandidateAction? = null
private set
fun update(tab: CandidateAction) {
tabAction = tab
ui.update(tab)
ui.root.setOnClickListener {
onTriggerTabAction(tab.id)
}
}
fun clear() {
tabAction = null
ui.root.setOnClickListener(null)
}
}
class TabActionUi(
override val ctx: Context,
val theme: Theme,
val indicatorStyle: Boolean = false
) : Ui {
private val label = view(::AutoScaleTextView) {
scaleMode = AutoScaleTextView.Mode.Proportional
textSize = 16f // sp
isSingleLine = true
gravity = gravityCenter
setTextColor(theme.candidateTextColor)
}
override val root = view(::CustomGestureView) {
background = createBackground(false, indicatorStyle)
add(label, lParams(matchParent, ctx.dp(36)) {
gravity = gravityCenter
})
}
var checkable = false
set(value) {
if (field == value) return
field = value
root.background = createBackground(value, indicatorStyle)
}
fun update(tab: CandidateAction) {
label.text = tab.text
checkable = tab.isCheckable
root.isActivated = tab.isChecked
}
private fun createBackground(checkable: Boolean, indicatorStyle: Boolean): Drawable {
if (!checkable) {
return pressHighlightDrawable(theme.keyPressHighlightColor)
}
if (!indicatorStyle) {
val highlight = ColorDrawable(theme.keyPressHighlightColor)
return StateListDrawable().apply {
addState(intArrayOf(android.R.attr.state_pressed), highlight)
addState(intArrayOf(android.R.attr.state_activated), highlight)
}
}
val states = arrayOf(
intArrayOf(android.R.attr.state_activated),
intArrayOf()
)
val colors = intArrayOf(
theme.accentKeyBackgroundColor,
theme.altKeyBackgroundColor
)
return LayerDrawable(
arrayOf(
ShapeDrawable(RectShape()).apply {
setTintList(ColorStateList(states, colors))
},
pressHighlightDrawable(theme.keyPressHighlightColor)
)
).apply {
setLayerSize(0, ctx.dp(11), ctx.dp(2))
setLayerGravity(0, Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL)
setLayerInsetBottom(0, ctx.dp(4))
}
}
}
}

View File

@ -6,6 +6,8 @@ package org.fcitx.fcitx5.android.input.candidates.expanded
import android.annotation.SuppressLint
import android.content.Context
import android.content.res.Configuration
import android.view.Gravity
import androidx.constraintlayout.widget.ConstraintLayout
import org.fcitx.fcitx5.android.R
import org.fcitx.fcitx5.android.data.theme.Theme
@ -16,8 +18,14 @@ import org.fcitx.fcitx5.android.input.keyboard.ImageKeyView
import org.fcitx.fcitx5.android.input.keyboard.ImageLayoutSwitchKey
import org.fcitx.fcitx5.android.input.keyboard.KeyDef
import org.fcitx.fcitx5.android.input.keyboard.ReturnKey
import org.fcitx.fcitx5.android.utils.setVerticalScrollbarThumbColor
import org.fcitx.fcitx5.android.utils.singleSideBorderDrawable
import splitties.dimensions.dp
import splitties.views.backgroundColor
import splitties.views.dsl.constraintlayout.above
import splitties.views.dsl.constraintlayout.bottomOfParent
import splitties.views.dsl.constraintlayout.centerHorizontally
import splitties.views.dsl.constraintlayout.constraintLayout
import splitties.views.dsl.constraintlayout.lParams
import splitties.views.dsl.constraintlayout.leftOfParent
import splitties.views.dsl.constraintlayout.leftToRightOf
@ -25,6 +33,7 @@ import splitties.views.dsl.constraintlayout.rightOfParent
import splitties.views.dsl.constraintlayout.rightToLeftOf
import splitties.views.dsl.constraintlayout.topOfParent
import splitties.views.dsl.core.add
import splitties.views.dsl.core.wrapContent
import splitties.views.dsl.recyclerview.recyclerView
import splitties.views.imageResource
@ -74,8 +83,30 @@ class ExpandedCandidateLayout(context: Context, theme: Theme) : ConstraintLayout
}
private val keyBorder by ThemeManager.prefs.keyBorder
private val keyVerticalMargin by ThemeManager.prefs.keyVerticalMargin
private val keyVerticalMarginLandscape by ThemeManager.prefs.keyVerticalMarginLandscape
val recyclerView = recyclerView {
// disable item cross-fade animation
itemAnimator = null
isVerticalScrollBarEnabled = false
}
val tabsContainer = constraintLayout()
val scrollableTabs = recyclerView {
itemAnimator = null
// always show scrollbar
isScrollbarFadingEnabled = false
scrollBarSize = dp(1)
setVerticalScrollbarThumbColor(theme.candidateTextColor)
}
val pinnedTabs = recyclerView {
itemAnimator = null
// prevent scrolling in pinned tabs at bottom
overScrollMode = OVER_SCROLL_NEVER
isNestedScrollingEnabled = false
isVerticalScrollBarEnabled = false
}
@ -89,13 +120,37 @@ class ExpandedCandidateLayout(context: Context, theme: Theme) : ConstraintLayout
init {
id = R.id.expanded_candidate_view
val landscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
val inset = dp(if (landscape) keyVerticalMarginLandscape else keyVerticalMargin)
tabsContainer.background =
singleSideBorderDrawable(dp(1), theme.dividerColor, Gravity.RIGHT, inset)
if (!keyBorder) {
backgroundColor = theme.barColor
embeddedKeyboard.background =
singleSideBorderDrawable(dp(1), theme.dividerColor, Gravity.LEFT, inset)
}
add(recyclerView, lParams {
add(tabsContainer, lParams {
matchConstraintPercentWidth = 0.15f
topOfParent()
leftOfParent()
bottomOfParent()
})
tabsContainer.apply {
add(scrollableTabs, lParams {
topOfParent()
centerHorizontally()
above(pinnedTabs)
})
add(pinnedTabs, lParams(height = wrapContent) {
bottomOfParent()
centerHorizontally()
})
}
add(recyclerView, lParams {
topOfParent()
leftToRightOf(tabsContainer)
rightToLeftOf(embeddedKeyboard)
bottomOfParent()
})

View File

@ -15,6 +15,8 @@ import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.fcitx.fcitx5.android.core.CandidateAction
import org.fcitx.fcitx5.android.core.FcitxEvent
import org.fcitx.fcitx5.android.daemon.launchOnReady
import org.fcitx.fcitx5.android.data.prefs.AppPrefs
import org.fcitx.fcitx5.android.input.bar.ExpandButtonStateMachine.BooleanKey.ExpandedCandidatesEmpty
@ -24,6 +26,7 @@ import org.fcitx.fcitx5.android.input.bar.KawaiiBarComponent
import org.fcitx.fcitx5.android.input.broadcast.InputBroadcastReceiver
import org.fcitx.fcitx5.android.input.broadcast.ReturnKeyDrawableComponent
import org.fcitx.fcitx5.android.input.candidates.CandidateViewHolder
import org.fcitx.fcitx5.android.input.candidates.expanded.CandidateTabActionsAdapter
import org.fcitx.fcitx5.android.input.candidates.expanded.CandidatesPagingSource
import org.fcitx.fcitx5.android.input.candidates.expanded.ExpandedCandidateLayout
import org.fcitx.fcitx5.android.input.candidates.expanded.PagingCandidateViewAdapter
@ -40,6 +43,7 @@ import org.fcitx.fcitx5.android.input.wm.InputWindow
import org.fcitx.fcitx5.android.input.wm.InputWindowManager
import org.mechdancer.dependency.manager.must
import splitties.dimensions.dp
import splitties.views.recyclerview.verticalLayoutManager
import kotlin.math.max
abstract class BaseExpandedCandidateWindow<T : BaseExpandedCandidateWindow<T>> :
@ -72,9 +76,13 @@ abstract class BaseExpandedCandidateWindow<T : BaseExpandedCandidateWindow<T>> :
final override fun onCreateView(): View {
candidateLayout = onCreateCandidateLayout().apply {
recyclerView.apply {
// disable item cross-fade animation
itemAnimator = null
scrollableTabs.apply {
adapter = tabsAdapter
layoutManager = verticalLayoutManager()
}
pinnedTabs.apply {
adapter = pinnedTabsAdapter
layoutManager = verticalLayoutManager()
}
}
return candidateLayout
@ -94,6 +102,34 @@ abstract class BaseExpandedCandidateWindow<T : BaseExpandedCandidateWindow<T>> :
abstract val adapter: PagingCandidateViewAdapter
abstract val layoutManager: RecyclerView.LayoutManager
val tabsAdapter by lazy {
object : CandidateTabActionsAdapter(theme, false) {
override fun onTriggerTabAction(id: Int) {
fcitx.launchOnReady { it.triggerCandidateListTabAction(id) }
}
}
}
val pinnedTabsAdapter by lazy {
object : CandidateTabActionsAdapter(theme, true) {
override fun onTriggerTabAction(id: Int) {
fcitx.launchOnReady { it.triggerCandidateListTabAction(id) }
}
}
}
private fun updateTabs(newTabs: Array<CandidateAction>) {
val tabs = newTabs.takeWhile { !it.isSeparator }
val pinnedTabs = newTabs.drop(tabs.size + 1).filter { !it.isSeparator }
if (tabs.isEmpty() && pinnedTabs.isEmpty()) {
candidateLayout.tabsContainer.visibility = View.GONE
} else {
candidateLayout.tabsContainer.visibility = View.VISIBLE
}
tabsAdapter.updateTabs(tabs)
pinnedTabsAdapter.updateTabs(pinnedTabs)
}
private var offsetJob: Job? = null
private val candidatesPager by lazy {
@ -123,6 +159,7 @@ abstract class BaseExpandedCandidateWindow<T : BaseExpandedCandidateWindow<T>> :
it.onReturnDrawableUpdate(returnKeyDrawable.resourceId)
it.keyActionListener = keyActionListener
}
updateTabs(fcitx.runImmediately { inputPanelCached.tabs })
offsetJob = service.lifecycleScope.launch {
horizontalCandidate.expandedCandidateOffset.collect {
if (it <= 0) {
@ -171,4 +208,8 @@ abstract class BaseExpandedCandidateWindow<T : BaseExpandedCandidateWindow<T>> :
}
}
override fun onInputPanelUpdate(data: FcitxEvent.InputPanelEvent.Data) {
updateTabs(data.tabs)
}
}

View File

@ -9,11 +9,14 @@ import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
import android.graphics.drawable.GradientDrawable
import android.graphics.drawable.LayerDrawable
import android.graphics.drawable.RippleDrawable
import android.graphics.drawable.ShapeDrawable
import android.graphics.drawable.StateListDrawable
import android.graphics.drawable.shapes.OvalShape
import android.view.Gravity
import androidx.annotation.ColorInt
import androidx.annotation.GravityInt
fun rippleDrawable(
@ColorInt color: Int,
@ -50,3 +53,23 @@ fun borderDrawable(
setStroke(width, stroke)
setColor(background)
}
fun singleSideBorderDrawable(
width: Int,
@ColorInt stroke: Int,
@GravityInt gravity: Int,
inset: Int = 0
): Drawable = LayerDrawable(
arrayOf(
GradientDrawable().apply { setColor(stroke) }
)
).apply {
setLayerGravity(0, gravity)
if (Gravity.isVertical(gravity)) {
setLayerHeight(0, width)
setLayerInset(0, inset, 0, inset, 0)
} else {
setLayerWidth(0, width)
setLayerInset(0, 0, inset, 0, inset)
}
}

View File

@ -0,0 +1,52 @@
/*
* SPDX-License-Identifier: LGPL-2.1-or-later
* SPDX-FileCopyrightText: Copyright 2026 Fcitx5 for Android Contributors
*/
package org.fcitx.fcitx5.android.utils
import android.annotation.SuppressLint
import android.graphics.drawable.Drawable
import android.graphics.drawable.GradientDrawable
import android.graphics.drawable.ShapeDrawable
import android.graphics.drawable.shapes.RectShape
import android.os.Build
import android.view.View
import androidx.annotation.ColorInt
import timber.log.Timber
import java.lang.reflect.Field
// android.view.View.ScrollabilityCache
private val scrollCacheField by lazy {
@SuppressLint("DiscouragedPrivateApi")
View::class.java.getDeclaredField("mScrollCache").apply { isAccessible = true }
}
// android.widget.ScrollBarDrawable
private val scrollBarField by lazy {
val scrollCacheClass = scrollCacheField.type
scrollCacheClass.getDeclaredField("scrollBar").apply { isAccessible = true }
}
// android.graphics.drawable.Drawable
private val verticalThumbField by lazy {
val scrollBarClass = scrollBarField.type
scrollBarClass.getDeclaredField("mVerticalThumb").apply { isAccessible = true }
}
fun View.setVerticalScrollbarThumbColor(@ColorInt color: Int) {
val drawable = ShapeDrawable(RectShape()).apply {
this.paint.color = color
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
verticalScrollbarThumbDrawable = drawable
} else {
try {
val scrollCache = scrollCacheField.get(this)
val scrollBar = scrollBarField.get(scrollCache)
verticalThumbField.set(scrollBar, drawable)
} catch (e: Exception) {
Timber.w(e, "Cannot set scrollbar color")
}
}
}

@ -1 +1 @@
Subproject commit 3a94a96006f8e67665fa9bb1b9ef4602415ddd04
Subproject commit 19ccd67d9e1348d6d14f23e224dc40bb13738bf7