mirror of
https://github.com/fcitx5-android/fcitx5-android.git
synced 2026-08-02 04:44:35 +08:00
Tab actions UI in expanded candidate window
This commit is contained in:
parent
873c08255c
commit
e205195f38
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
})
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
Loading…
x
Reference in New Issue
Block a user