Improve tablet navigation

This commit is contained in:
Koitharu
2025-05-25 09:56:31 +03:00
parent 12f1ffd019
commit d3d7912bb8
13 changed files with 245 additions and 31 deletions

View File

@@ -0,0 +1,97 @@
package org.koitharu.kotatsu.core.ui.widgets
import android.content.Context
import android.os.Parcel
import android.os.Parcelable
import android.os.Parcelable.Creator
import android.util.AttributeSet
import android.widget.Checkable
import androidx.annotation.AttrRes
import androidx.appcompat.widget.AppCompatImageButton
import androidx.core.os.ParcelCompat
import androidx.customview.view.AbsSavedState
class CheckableImageButton @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
@AttrRes defStyleAttr: Int = 0,
) : AppCompatImageButton(context, attrs, defStyleAttr), Checkable {
private var isCheckedInternal = false
private var isBroadcasting = false
var onCheckedChangeListener: OnCheckedChangeListener? = null
override fun isChecked() = isCheckedInternal
override fun toggle() {
isChecked = !isCheckedInternal
}
override fun setChecked(checked: Boolean) {
if (checked != isCheckedInternal) {
isCheckedInternal = checked
refreshDrawableState()
if (!isBroadcasting) {
isBroadcasting = true
onCheckedChangeListener?.onCheckedChanged(this, checked)
isBroadcasting = false
}
}
}
override fun onCreateDrawableState(extraSpace: Int): IntArray {
val state = super.onCreateDrawableState(extraSpace + 1)
if (isCheckedInternal) {
mergeDrawableStates(state, intArrayOf(android.R.attr.state_checked))
}
return state
}
override fun onSaveInstanceState(): Parcelable? {
val superState = super.onSaveInstanceState() ?: return null
return SavedState(superState, isChecked)
}
override fun onRestoreInstanceState(state: Parcelable?) {
if (state is SavedState) {
super.onRestoreInstanceState(state.superState)
isChecked = state.isChecked
} else {
super.onRestoreInstanceState(state)
}
}
fun interface OnCheckedChangeListener {
fun onCheckedChanged(view: CheckableImageButton, isChecked: Boolean)
}
private class SavedState : AbsSavedState {
val isChecked: Boolean
constructor(superState: Parcelable, checked: Boolean) : super(superState) {
isChecked = checked
}
constructor(source: Parcel, classLoader: ClassLoader?) : super(source, classLoader) {
isChecked = ParcelCompat.readBoolean(source)
}
override fun writeToParcel(out: Parcel, flags: Int) {
super.writeToParcel(out, flags)
ParcelCompat.writeBoolean(out, isChecked)
}
companion object {
@Suppress("unused")
@JvmField
val CREATOR: Creator<SavedState> = object : Creator<SavedState> {
override fun createFromParcel(`in`: Parcel) = SavedState(`in`, SavedState::class.java.classLoader)
override fun newArray(size: Int): Array<SavedState?> = arrayOfNulls(size)
}
}
}
}

View File

@@ -6,8 +6,10 @@ import android.view.View
import android.view.View.MeasureSpec
import android.view.ViewGroup
import android.widget.Checkable
import androidx.annotation.StringRes
import androidx.appcompat.widget.ActionMenuView
import androidx.appcompat.widget.Toolbar
import androidx.appcompat.widget.TooltipCompat
import androidx.core.view.children
import androidx.core.view.descendants
import androidx.core.view.isVisible
@@ -192,3 +194,9 @@ fun Chip.setProgressIcon() {
chipIcon = progressDrawable
progressDrawable.start()
}
fun View.setContentDescriptionAndTooltip(@StringRes resId: Int) {
val text = resources.getString(resId)
contentDescription = text
TooltipCompat.setTooltipText(this, text)
}

View File

@@ -14,6 +14,7 @@ import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe
import org.koitharu.kotatsu.core.util.ext.setContentDescriptionAndTooltip
import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ItemDownloadBinding
import org.koitharu.kotatsu.download.ui.list.chapters.DownloadChapter
@@ -41,7 +42,7 @@ fun downloadItemAD(
R.id.button_skip -> listener.onSkipClick(item)
R.id.button_skip_all -> listener.onSkipAllClick(item)
R.id.button_pause -> listener.onPauseClick(item)
R.id.imageView_expand -> listener.onExpandClick(item)
R.id.button_expand -> listener.onExpandClick(item)
else -> listener.onItemClick(item, v)
}
}
@@ -59,7 +60,7 @@ fun downloadItemAD(
binding.buttonResume.setOnClickListener(clickListener)
binding.buttonSkip.setOnClickListener(clickListener)
binding.buttonSkipAll.setOnClickListener(clickListener)
binding.imageViewExpand.setOnClickListener(clickListener)
binding.buttonExpand.setOnClickListener(clickListener)
itemView.setOnClickListener(clickListener)
itemView.setOnLongClickListener(clickListener)
@@ -83,7 +84,7 @@ fun downloadItemAD(
chaptersJob?.cancel()
chaptersJob = lifecycleOwner.lifecycleScope.launch(start = CoroutineStart.UNDISPATCHED) {
item.chapters.collect { chapters ->
binding.imageViewExpand.isGone = chapters.isNullOrEmpty()
binding.buttonExpand.isGone = chapters.isNullOrEmpty()
chaptersAdapter.emit(chapters)
scrollToCurrentChapter()
}
@@ -93,7 +94,8 @@ fun downloadItemAD(
scrollToCurrentChapter()
}
}
binding.imageViewExpand.isChecked = item.isExpanded
binding.buttonExpand.isChecked = item.isExpanded
binding.buttonExpand.setContentDescriptionAndTooltip(if (item.isExpanded) R.string.collapse else R.string.expand)
binding.recyclerViewChapters.isVisible = item.isExpanded
when (item.workState) {
WorkInfo.State.ENQUEUED,

View File

@@ -120,6 +120,9 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
)
navigationDelegate.addOnFragmentChangedListener(this)
navigationDelegate.onCreate(this, savedInstanceState)
viewBinding.textViewTitle?.let { tv ->
navigationDelegate.observeTitle().observe(this) { tv.text = it }
}
addMenuProvider(MainMenuProvider(router, viewModel))
@@ -409,7 +412,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
}
}
private fun SearchView.observeState() = callbackFlow<SearchView.TransitionState> {
private fun SearchView.observeState() = callbackFlow {
val listener = SearchView.TransitionListener { _, _, state ->
trySendBlocking(state)
}

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.main.ui
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View
import androidx.activity.OnBackPressedCallback
import androidx.annotation.IdRes
import androidx.core.view.isEmpty
@@ -13,11 +14,16 @@ import androidx.fragment.app.FragmentManager
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.google.android.material.navigation.NavigationBarView
import com.google.android.material.navigationrail.NavigationRailView
import com.google.android.material.transition.MaterialFadeThrough
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import org.koitharu.kotatsu.R
@@ -26,7 +32,9 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.NavItem
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.core.ui.widgets.SlidingBottomNavigationView
import org.koitharu.kotatsu.core.util.ext.setContentDescriptionAndTooltip
import org.koitharu.kotatsu.core.util.ext.smoothScrollToTop
import org.koitharu.kotatsu.databinding.NavigationRailFabBinding
import org.koitharu.kotatsu.explore.ui.ExploreFragment
import org.koitharu.kotatsu.favourites.ui.container.FavouritesContainerFragment
import org.koitharu.kotatsu.history.ui.HistoryListFragment
@@ -45,9 +53,12 @@ class MainNavigationDelegate(
private val settings: AppSettings,
) : OnBackPressedCallback(false),
NavigationBarView.OnItemSelectedListener,
NavigationBarView.OnItemReselectedListener {
NavigationBarView.OnItemReselectedListener, View.OnClickListener {
private val listeners = LinkedList<OnFragmentChangedListener>()
private val navRailHeader = (navBar as? NavigationRailView)?.headerView?.let {
NavigationRailFabBinding.bind(it)
}
val primaryFragment: Fragment?
get() = fragmentManager.findFragmentByTag(TAG_PRIMARY)
@@ -55,6 +66,14 @@ class MainNavigationDelegate(
init {
navBar.setOnItemSelectedListener(this)
navBar.setOnItemReselectedListener(this)
navRailHeader?.run {
val horizontalPadding = (navBar as NavigationRailView).itemActiveIndicatorExpandedMarginHorizontal
root.setPadding(horizontalPadding, 0, horizontalPadding, 0)
buttonExpand.setOnClickListener(this@MainNavigationDelegate)
buttonExpand.setContentDescriptionAndTooltip(R.string.expand)
railFab.isExtended = false
railFab.isAnimationEnabled = false
}
}
override fun onNavigationItemSelected(item: MenuItem): Boolean {
@@ -70,6 +89,30 @@ class MainNavigationDelegate(
onNavigationItemReselected()
}
override fun onClick(v: View) {
when (v.id) {
R.id.button_expand -> {
if (navBar is NavigationRailView) {
if (navBar.isExpanded) {
navBar.collapse()
navRailHeader?.run {
railFab.shrink()
buttonExpand.setImageResource(R.drawable.ic_drawer_menu)
buttonExpand.setContentDescriptionAndTooltip(R.string.expand)
}
} else {
navBar.expand()
navRailHeader?.run {
railFab.extend()
buttonExpand.setImageResource(R.drawable.ic_drawer_menu_open)
buttonExpand.setContentDescriptionAndTooltip(R.string.collapse)
}
}
}
}
}
}
override fun handleOnBackPressed() {
navBar.selectedItemId = firstItem()?.itemId ?: return
}
@@ -96,6 +139,16 @@ class MainNavigationDelegate(
}
}
fun observeTitle() = callbackFlow {
val listener = OnFragmentChangedListener { f, _ ->
trySendBlocking(getItemId(f))
}
addOnFragmentChangedListener(listener)
awaitClose { removeOnFragmentChangedListener(listener) }
}.map {
navBar.menu.findItem(it)?.title
}
fun setCounter(item: NavItem, counter: Int) {
setCounter(item.id, counter)
}
@@ -248,7 +301,7 @@ class MainNavigationDelegate(
}
}
interface OnFragmentChangedListener {
fun interface OnFragmentChangedListener {
fun onFragmentChanged(fragment: Fragment, fromUser: Boolean)
}

View File

@@ -12,9 +12,7 @@ import android.widget.Button
import android.widget.FrameLayout
import android.widget.LinearLayout
import androidx.annotation.AttrRes
import androidx.annotation.StringRes
import androidx.appcompat.widget.TooltipCompat
import androidx.core.view.ViewCompat
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import com.google.android.material.slider.Slider
@@ -25,6 +23,7 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ReaderControl
import org.koitharu.kotatsu.core.util.ext.hasVisibleChildren
import org.koitharu.kotatsu.core.util.ext.isRtl
import org.koitharu.kotatsu.core.util.ext.setContentDescriptionAndTooltip
import org.koitharu.kotatsu.core.util.ext.setValueRounded
import org.koitharu.kotatsu.databinding.LayoutReaderActionsBinding
import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesSheet
@@ -134,6 +133,7 @@ class ReaderActionsView @JvmOverloads constructor(
override fun onLongClick(v: View): Boolean = when (v.id) {
R.id.button_bookmark -> AppRouter.from(this)
?.showChapterPagesSheet(ChaptersPagesSheet.TAB_BOOKMARKS)
R.id.button_timer -> listener?.onScrollTimerClick(isLongClick = true)
R.id.button_options -> AppRouter.from(this)?.openReaderSettings()
else -> null
@@ -206,7 +206,7 @@ class ReaderActionsView @JvmOverloads constructor(
button.setIconResource(
if (isPagesMode) R.drawable.ic_grid else R.drawable.ic_list,
)
button.setTitle(
button.setContentDescriptionAndTooltip(
if (isPagesMode) R.string.pages else R.string.chapters,
)
}
@@ -216,7 +216,7 @@ class ReaderActionsView @JvmOverloads constructor(
button.setIconResource(
if (isBookmarkAdded) R.drawable.ic_bookmark_added else R.drawable.ic_bookmark,
)
button.setTitle(
button.setContentDescriptionAndTooltip(
if (isBookmarkAdded) R.string.bookmark_remove else R.string.bookmark_add,
)
}
@@ -240,12 +240,12 @@ class ReaderActionsView @JvmOverloads constructor(
when {
!button.isVisible -> return
isAutoRotationEnabled() -> {
button.setTitle(R.string.lock_screen_rotation)
button.setContentDescriptionAndTooltip(R.string.lock_screen_rotation)
button.setIconResource(R.drawable.ic_screen_rotation_lock)
}
else -> {
button.setTitle(R.string.rotate_screen)
button.setContentDescriptionAndTooltip(R.string.rotate_screen)
button.setIconResource(R.drawable.ic_screen_rotation)
}
}
@@ -257,12 +257,6 @@ class ReaderActionsView @JvmOverloads constructor(
TooltipCompat.setTooltipText(this, contentDescription)
}
private fun Button.setTitle(@StringRes titleResId: Int) {
val text = resources.getString(titleResId)
contentDescription = text
TooltipCompat.setTooltipText(this, text)
}
private fun isAutoRotationEnabled(): Boolean = Settings.System.getInt(
context.contentResolver,
Settings.System.ACCELEROMETER_ROTATION,

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@android:color/white"
android:pathData="M3,18h18v-2L3,16v2zM3,13h18v-2L3,11v2zM3,6v2h18L21,6L3,6z" />
</vector>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:tint="?attr/colorControlNormal"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M120,720v-80L640,640v80L120,720ZM784,680L584,480L784,280l56,56L696,480L840,624l-56,56ZM120,520v-80L520,440v80L120,520ZM120,320v-80L640,240v80L120,320Z" />
</vector>

View File

@@ -12,6 +12,7 @@
android:id="@+id/navRail"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:clipToPadding="false"
android:fitsSystemWindows="false"
app:elevation="1dp"
app:headerLayout="@layout/navigation_rail_fab"
@@ -60,6 +61,16 @@
android:layout_height="wrap_content"
app:layout_scrollFlags="scroll|enterAlways|snap">
<TextView
android:id="@+id/textView_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical|start"
android:paddingStart="@dimen/screen_padding"
android:textAppearance="?textAppearanceTitleLarge"
tools:ignore="RtlSymmetry"
tools:text="@string/history" />
<com.google.android.material.search.SearchBar
android:id="@+id/search_bar"
android:layout_width="match_parent"

View File

@@ -34,23 +34,21 @@
android:gravity="center_vertical"
android:singleLine="true"
android:textAppearance="?attr/textAppearanceTitleSmall"
app:layout_constraintEnd_toStartOf="@id/imageView_expand"
app:layout_constraintEnd_toStartOf="@id/button_expand"
app:layout_constraintStart_toEndOf="@id/imageView_cover"
app:layout_constraintTop_toTopOf="parent"
app:layout_goneMarginEnd="12dp"
tools:text="@tools:sample/lorem" />
<org.koitharu.kotatsu.core.ui.widgets.CheckableImageView
android:id="@+id/imageView_expand"
<org.koitharu.kotatsu.core.ui.widgets.CheckableImageButton
android:id="@+id/button_expand"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?selectableItemBackgroundBorderless"
android:contentDescription="@string/details"
android:contentDescription="@string/expand"
android:minWidth="?minTouchTargetSize"
android:minHeight="?minTouchTargetSize"
android:pointerIcon="hand"
android:scaleType="center"
android:tooltipText="@string/details"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
@@ -63,7 +61,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:barrierDirection="bottom"
app:constraint_referenced_ids="imageView_cover,textView_status,imageView_expand,textView_details" />
app:constraint_referenced_ids="imageView_cover,textView_status,button_expand,textView_details" />
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progressBar"

View File

@@ -14,6 +14,7 @@
android:id="@+id/chips_genres"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:clipChildren="false"
android:paddingTop="6dp"
android:paddingBottom="12dp"
app:singleLine="true" />

View File

@@ -1,9 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.floatingactionbutton.FloatingActionButton
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/railFab"
android:layout_width="wrap_content"
android:id="@+id/cat_navigation_rail_efab_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:contentDescription="@string/_continue"
app:srcCompat="@drawable/ic_read" />
android:layout_gravity="top|start"
android:clipChildren="false"
android:clipToPadding="false"
android:orientation="vertical">
<ImageButton
android:id="@+id/button_expand"
android:layout_width="56dp"
android:layout_height="56dp"
android:background="?selectableItemBackgroundBorderless"
android:contentDescription="@string/expand"
android:scaleType="center"
android:src="@drawable/ic_drawer_menu" />
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
android:id="@+id/railFab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/_continue"
app:icon="@drawable/ic_read" />
</LinearLayout>

View File

@@ -840,4 +840,6 @@
<string name="hide_from_main_screen">Hide from main screen</string>
<string name="changelog">Changelog</string>
<string name="changelog_summary">Changes history for recently released versions</string>
<string name="collapse">Collapse</string>
<string name="expand">Expand</string>
</resources>