refactor: Use LinearProgressIndicator with delayed show/hide (#525)

Previous code used a normal ProgressBar and a coroutine to delay
hiding/showing the bar for a snappier UI perception.

This is built-in functionality in LinearProgressIndicator, so switch to
that.

While I'm here, implement the "Select list" dialog's layout as a layout
resource.
This commit is contained in:
Nik Clayton 2024-03-13 10:03:10 +01:00 committed by GitHub
parent 006c358053
commit cfc15c8f64
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 66 additions and 79 deletions

View File

@ -19,13 +19,10 @@ package app.pachli
import android.graphics.Color import android.graphics.Color
import android.os.Bundle import android.os.Bundle
import android.view.Gravity
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ArrayAdapter import android.widget.ArrayAdapter
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.TextView import android.widget.TextView
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
@ -42,7 +39,6 @@ import app.pachli.adapter.TabAdapter
import app.pachli.appstore.EventHub import app.pachli.appstore.EventHub
import app.pachli.appstore.MainTabsChangedEvent import app.pachli.appstore.MainTabsChangedEvent
import app.pachli.core.activity.BaseActivity import app.pachli.core.activity.BaseActivity
import app.pachli.core.common.extensions.hide
import app.pachli.core.common.extensions.show import app.pachli.core.common.extensions.show
import app.pachli.core.common.extensions.viewBinding import app.pachli.core.common.extensions.viewBinding
import app.pachli.core.common.extensions.visible import app.pachli.core.common.extensions.visible
@ -55,7 +51,7 @@ import app.pachli.core.navigation.ListActivityIntent
import app.pachli.core.network.model.MastoList import app.pachli.core.network.model.MastoList
import app.pachli.core.network.retrofit.MastodonApi import app.pachli.core.network.retrofit.MastodonApi
import app.pachli.databinding.ActivityTabPreferenceBinding import app.pachli.databinding.ActivityTabPreferenceBinding
import app.pachli.util.getDimension import app.pachli.databinding.DialogSelectListBinding
import app.pachli.util.unsafeLazy import app.pachli.util.unsafeLazy
import at.connyduck.sparkbutton.helpers.Utils import at.connyduck.sparkbutton.helpers.Utils
import com.github.michaelbull.result.onFailure import com.github.michaelbull.result.onFailure
@ -67,10 +63,7 @@ import com.google.android.material.transition.MaterialContainerTransform
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import java.util.regex.Pattern import java.util.regex.Pattern
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
@ -296,26 +289,13 @@ class TabPreferenceActivity : BaseActivity(), ItemInteractionListener {
} }
} }
val statusLayout = LinearLayout(this) val selectListBinding = DialogSelectListBinding.inflate(layoutInflater, null, false)
statusLayout.gravity = Gravity.CENTER
val progress = ProgressBar(this)
val preferredPadding = getDimension(this, androidx.appcompat.R.attr.dialogPreferredPadding)
progress.setPadding(preferredPadding, 0, preferredPadding, 0)
progress.visible(false)
val noListsText = TextView(this)
noListsText.setPadding(preferredPadding, 0, preferredPadding, 0)
noListsText.text = getText(R.string.select_list_empty)
noListsText.visible(false)
statusLayout.addView(progress)
statusLayout.addView(noListsText)
val dialog = AlertDialog.Builder(this) val dialog = AlertDialog.Builder(this)
.setTitle(R.string.select_list_title) .setTitle(R.string.select_list_title)
.setNeutralButton(R.string.select_list_manage, null) .setNeutralButton(R.string.select_list_manage, null)
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
.setView(statusLayout) .setView(selectListBinding.root)
.setAdapter(adapter) { _, position -> .setAdapter(adapter) { _, position ->
adapter.getItem(position)?.let { item -> adapter.getItem(position)?.let { item ->
val newTab = TabViewData.from(TabData.UserList(item.id, item.title)) val newTab = TabViewData.from(TabData.UserList(item.id, item.title))
@ -326,8 +306,7 @@ class TabPreferenceActivity : BaseActivity(), ItemInteractionListener {
} }
}.create() }.create()
val showProgressBarJob = getProgressBarJob(progress, 500) selectListBinding.progressBar.show()
showProgressBarJob.start()
// Set the "Manage lists" button listener after creating the dialog. This ensures // Set the "Manage lists" button listener after creating the dialog. This ensures
// that clicking the button does not dismiss the dialog, so when the user returns // that clicking the button does not dismiss the dialog, so when the user returns
@ -338,21 +317,23 @@ class TabPreferenceActivity : BaseActivity(), ItemInteractionListener {
startActivity(ListActivityIntent(applicationContext)) startActivity(ListActivityIntent(applicationContext))
} }
} }
dialog.show() dialog.show()
lifecycleScope.launch { lifecycleScope.launch {
listsRepository.lists.collect { result -> listsRepository.lists.collect { result ->
result.onSuccess { lists -> result.onSuccess { lists ->
if (lists is Lists.Loaded) { if (lists is Lists.Loaded) {
showProgressBarJob.cancel() selectListBinding.progressBar.hide()
adapter.clear() adapter.clear()
adapter.addAll(lists.lists.sortedWith(compareByListTitle)) adapter.addAll(lists.lists.sortedWith(compareByListTitle))
if (lists.lists.isEmpty()) noListsText.show() if (lists.lists.isEmpty()) selectListBinding.noLists.show()
} }
} }
result.onFailure { result.onFailure {
dialog.hide() selectListBinding.progressBar.hide()
dialog.dismiss()
Snackbar.make(binding.root, R.string.error_list_load, Snackbar.LENGTH_LONG).show() Snackbar.make(binding.root, R.string.error_list_load, Snackbar.LENGTH_LONG).show()
Timber.w(it.throwable, "failed to load lists") Timber.w(it.throwable, "failed to load lists")
} }
@ -360,18 +341,6 @@ class TabPreferenceActivity : BaseActivity(), ItemInteractionListener {
} }
} }
private fun getProgressBarJob(progressView: View, delayMs: Long) = this.lifecycleScope.launch(
start = CoroutineStart.LAZY,
) {
try {
delay(delayMs)
progressView.show()
awaitCancellation()
} finally {
progressView.hide()
}
}
private fun validateHashtag(input: CharSequence?): Boolean { private fun validateHashtag(input: CharSequence?): Boolean {
val trimmedInput = input?.trim() ?: "" val trimmedInput = input?.trim() ?: ""
return trimmedInput.isNotEmpty() && hashtagRegex.matcher(trimmedInput).matches() return trimmedInput.isNotEmpty() && hashtagRegex.matcher(trimmedInput).matches()

View File

@ -23,7 +23,6 @@ import android.view.MenuInflater
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.annotation.CheckResult
import androidx.core.view.MenuProvider import androidx.core.view.MenuProvider
import androidx.fragment.app.commit import androidx.fragment.app.commit
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
@ -53,9 +52,6 @@ import com.google.android.material.color.MaterialColors
import com.google.android.material.divider.MaterialDividerItemDecoration import com.google.android.material.divider.MaterialDividerItemDecoration
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
@ -130,9 +126,6 @@ class ViewThreadFragment :
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false (binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
var initialProgressBar = getProgressBarJob(binding.initialProgressBar, 500)
var threadProgressBar = getProgressBarJob(binding.threadProgressBar, 500)
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
viewModel.uiState.collect { uiState -> viewModel.uiState.collect { uiState ->
when (uiState) { when (uiState) {
@ -142,8 +135,7 @@ class ViewThreadFragment :
binding.recyclerView.hide() binding.recyclerView.hide()
binding.statusView.hide() binding.statusView.hide()
initialProgressBar = getProgressBarJob(binding.initialProgressBar, 500) binding.initialProgressBar.show()
initialProgressBar.start()
} }
is ThreadUiState.LoadingThread -> { is ThreadUiState.LoadingThread -> {
if (uiState.statusViewDatum == null) { if (uiState.statusViewDatum == null) {
@ -152,9 +144,8 @@ class ViewThreadFragment :
return@collect return@collect
} }
initialProgressBar.cancel() binding.initialProgressBar.hide()
threadProgressBar = getProgressBarJob(binding.threadProgressBar, 500) binding.threadProgressBar.show()
threadProgressBar.start()
if (viewModel.isInitialLoad) { if (viewModel.isInitialLoad) {
adapter.submitList(listOf(uiState.statusViewDatum)) adapter.submitList(listOf(uiState.statusViewDatum))
@ -170,8 +161,8 @@ class ViewThreadFragment :
} }
is ThreadUiState.Error -> { is ThreadUiState.Error -> {
Timber.w(uiState.throwable, "failed to load status") Timber.w(uiState.throwable, "failed to load status")
initialProgressBar.cancel() binding.initialProgressBar.hide()
threadProgressBar.cancel() binding.threadProgressBar.hide()
revealButtonState = RevealButtonState.NO_BUTTON revealButtonState = RevealButtonState.NO_BUTTON
binding.swipeRefreshLayout.isRefreshing = false binding.swipeRefreshLayout.isRefreshing = false
@ -188,7 +179,7 @@ class ViewThreadFragment :
return@collect return@collect
} }
threadProgressBar.cancel() binding.threadProgressBar.hide()
adapter.submitList(uiState.statusViewData) { adapter.submitList(uiState.statusViewData) {
if (lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) && viewModel.isInitialLoad) { if (lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) && viewModel.isInitialLoad) {
@ -209,7 +200,7 @@ class ViewThreadFragment :
binding.statusView.hide() binding.statusView.hide()
} }
is ThreadUiState.Refreshing -> { is ThreadUiState.Refreshing -> {
threadProgressBar.cancel() binding.threadProgressBar.hide()
} }
} }
} }
@ -275,28 +266,6 @@ class ViewThreadFragment :
requireActivity().title = getString(R.string.title_view_thread) requireActivity().title = getString(R.string.title_view_thread)
} }
/**
* Create a job to implement a delayed-visible progress bar.
*
* Delaying the visibility of the progress bar can improve user perception of UI speed because
* fewer UI elements are appearing and disappearing.
*
* When started the job will wait `delayMs` then show `view`. If the job is cancelled at
* any time `view` is hidden.
*/
@CheckResult
private fun getProgressBarJob(view: View, delayMs: Long) = viewLifecycleOwner.lifecycleScope.launch(
start = CoroutineStart.LAZY,
) {
try {
delay(delayMs)
view.show()
awaitCancellation()
} finally {
view.hide()
}
}
override fun onRefresh() { override fun onRefresh() {
viewModel.refresh(thisThreadsStatusId) viewModel.refresh(thisThreadsStatusId)
} }

View File

@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright 2024 Pachli Association
~
~ This file is a part of Pachli.
~
~ This program is free software; you can redistribute it and/or modify it under the terms of the
~ GNU General Public License as published by the Free Software Foundation; either version 3 of the
~ License, or (at your option) any later version.
~
~ Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
~ the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
~ Public License for more details.
~
~ You should have received a copy of the GNU General Public License along with Pachli; if not,
~ see <http://www.gnu.org/licenses>.
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:layout_gravity="top">
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progressBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="?dialogPreferredPadding"
android:paddingEnd="?dialogPreferredPadding"
android:layout_gravity="top"
android:indeterminate="true"
android:visibility="gone" />
<TextView
android:id="@+id/noLists"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="?dialogPreferredPadding"
android:paddingEnd="?dialogPreferredPadding"
android:text="@string/select_list_empty"
android:visibility="gone" />
</LinearLayout>

View File

@ -45,7 +45,7 @@
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout> </androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</LinearLayout> </LinearLayout>
<ProgressBar <com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/initialProgressBar" android:id="@+id/initialProgressBar"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"

View File

@ -84,6 +84,8 @@
<item name="graphViewStyle">@style/Pachli.Widget.GraphView</item> <item name="graphViewStyle">@style/Pachli.Widget.GraphView</item>
<item name="snackbarTextViewStyle">@style/snackbar_text</item> <item name="snackbarTextViewStyle">@style/snackbar_text</item>
<item name="linearProgressIndicatorStyle">@style/Pachli.Widget.Material3.LinearProgressIndicator</item>
</style> </style>
<style name="AppTheme" parent="Theme.Pachli" /> <style name="AppTheme" parent="Theme.Pachli" />
@ -169,6 +171,11 @@
<item name="lineWidth">2sp</item> <item name="lineWidth">2sp</item>
</style> </style>
<style name="Pachli.Widget.Material3.LinearProgressIndicator" parent="Widget.Material3.LinearProgressIndicator">
<item name="minHideDelay">500</item>
<item name="showDelay">500</item>
</style>
<style name="Theme.Pachli.Black" parent="Base.Theme.Black" /> <style name="Theme.Pachli.Black" parent="Base.Theme.Black" />
<!-- customize the shape of the avatars in account selection list --> <!-- customize the shape of the avatars in account selection list -->