feat: Auto-hide Image Viewer toolbar (#507)

Previous code showed the toolbar and caption when displaying the image,
and the user has to tap the screen to dismiss it.

New code is consistent with the video viewer; the toolbar and caption
is displayed for two seconds, and then auto-hides.

- Tapping after the hide displays the toolbar and caption, which will
  not auto-hide and requires a second tap to dismiss.

- Tapping the caption before it's hidden cancels the auto-hide

Fixes #505
This commit is contained in:
Angelo Suzuki 2024-03-10 23:13:40 +01:00 committed by GitHub
parent 0445e187df
commit bdf2d9329e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 116 additions and 39 deletions

View File

@ -28,10 +28,10 @@ import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.view.GestureDetectorCompat
import androidx.lifecycle.lifecycleScope
import app.pachli.R
import app.pachli.ViewMediaActivity
import app.pachli.core.common.extensions.hide
import app.pachli.core.common.extensions.viewBinding
import app.pachli.core.common.extensions.visible
@ -41,6 +41,8 @@ import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.target.Target
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback
import com.ortiz.touchview.OnTouchCoordinatesListener
import com.ortiz.touchview.TouchImageView
import kotlin.math.abs
@ -50,24 +52,29 @@ class ViewImageFragment : ViewMediaFragment() {
private val binding by viewBinding(FragmentViewImageBinding::bind)
private lateinit var toolbar: View
// Volatile: Image requests happen on background thread and we want to see updates to it
// immediately on another thread. Atomic is an overkill for such thing.
@Volatile
private var startedTransition = false
override fun setupMediaView(showingDescription: Boolean) {
private var scheduleToolbarHide = false
override fun setupMediaView(
isToolbarVisible: Boolean,
showingDescription: Boolean,
) {
binding.photoView.transitionName = attachment.url
binding.mediaDescription.text = attachment.description
binding.captionSheet.visible(showingDescription)
startedTransition = false
loadImageFromNetwork(attachment.url, attachment.previewUrl, binding.photoView)
// Only schedule hiding the toolbar once
scheduleToolbarHide = isToolbarVisible
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
toolbar = (requireActivity() as ViewMediaActivity).toolbar
return inflater.inflate(R.layout.fragment_view_image, container, false)
}
@ -183,9 +190,19 @@ class ViewImageFragment : ViewMediaFragment() {
}
},
)
val captionSheetParams = (binding.captionSheet.layoutParams as CoordinatorLayout.LayoutParams)
(captionSheetParams.behavior as BottomSheetBehavior).addBottomSheetCallback(
object : BottomSheetCallback() {
override fun onStateChanged(bottomSheet: View, newState: Int) = cancelToolbarHide()
override fun onSlide(bottomSheet: View, slideOffset: Float) = cancelToolbarHide()
},
)
}
override fun onToolbarVisibilityChange(visible: Boolean) {
super.onToolbarVisibilityChange(visible)
if (!userVisibleHint) return
isDescriptionVisible = showingDescription && visible
@ -203,6 +220,15 @@ class ViewImageFragment : ViewMediaFragment() {
.start()
}
override fun shouldScheduleToolbarHide(): Boolean {
return if (scheduleToolbarHide) {
scheduleToolbarHide = false
true
} else {
false
}
}
override fun onStop() {
super.onStop()
Glide.with(this).clear(binding.photoView)

View File

@ -17,15 +17,22 @@
package app.pachli.fragment
import android.content.Context
import android.os.Build
import android.os.Bundle
import android.text.TextUtils
import android.view.View
import androidx.annotation.CallSuper
import androidx.annotation.OptIn
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.media3.common.util.UnstableApi
import app.pachli.ViewMediaActivity
import app.pachli.core.network.model.Attachment
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/** Interface for actions that may happen while media is being displayed */
interface MediaActionsListener {
@ -51,16 +58,56 @@ abstract class ViewMediaFragment : Fragment() {
* Called after [onResume], subclasses should override this and update
* the contents of views (including loading any media).
*
* @param isToolbarVisible True if the toolbar is visible
* @param showingDescription True if the media's description should be shown
*/
abstract fun setupMediaView(showingDescription: Boolean)
abstract fun setupMediaView(
isToolbarVisible: Boolean,
showingDescription: Boolean,
)
/**
* Called when the visibility of the toolbar changes.
*
* @param visible True if the toolbar is visible
*/
abstract fun onToolbarVisibilityChange(visible: Boolean)
@CallSuper
protected open fun onToolbarVisibilityChange(visible: Boolean) {
if (visible && shouldScheduleToolbarHide()) {
hideToolbarAfterDelay()
} else {
hideToolbarJob?.cancel()
}
}
/**
* Called when the toolbar becomes visible, returns whether or not to schedule hiding the toolbar
*/
protected abstract fun shouldScheduleToolbarHide(): Boolean
/** Hoist toolbar hiding to activity so it can track state across different fragments */
private var hideToolbarJob: Job? = null
/**
* Schedule hiding the toolbar after a delay
*/
protected fun hideToolbarAfterDelay() {
hideToolbarJob?.cancel()
hideToolbarJob = lifecycleScope.launch {
delay(CONTROLS_TIMEOUT)
mediaActionsListener.onMediaTap()
}
}
/**
* Cancel previously scheduled hiding of the toolbar
*/
protected fun cancelToolbarHide() {
hideToolbarJob?.cancel()
}
protected lateinit var mediaActivity: ViewMediaActivity
private set
protected var showingDescription = false
protected var isDescriptionVisible = false
@ -82,6 +129,7 @@ abstract class ViewMediaFragment : Fragment() {
override fun onAttach(context: Context) {
super.onAttach(context)
mediaActivity = activity as ViewMediaActivity
mediaActionsListener = context as MediaActionsListener
}
@ -109,18 +157,36 @@ abstract class ViewMediaFragment : Fragment() {
}
private fun finalizeViewSetup() {
val mediaActivity = activity as ViewMediaActivity
showingDescription = !TextUtils.isEmpty(attachment.description)
isDescriptionVisible = showingDescription
setupMediaView(showingDescription && mediaActivity.isToolbarVisible)
setupMediaView(mediaActivity.isToolbarVisible, showingDescription && mediaActivity.isToolbarVisible)
removeToolbarListener = (activity as ViewMediaActivity)
removeToolbarListener = mediaActivity
.addToolbarVisibilityListener { isVisible ->
onToolbarVisibilityChange(isVisible)
}
}
override fun onPause() {
super.onPause()
// If <= API 23 then multi-window mode is not available, so this is a good time to
// pause everything
if (Build.VERSION.SDK_INT <= 23) {
hideToolbarJob?.cancel()
}
}
override fun onStop() {
super.onStop()
// If > API 23 then this might be multi-window, and definitely wasn't paused in onPause,
// so pause everything now.
if (Build.VERSION.SDK_INT > 23) {
hideToolbarJob?.cancel()
}
}
override fun onDestroyView() {
removeToolbarListener?.invoke()
transitionComplete = null
@ -132,6 +198,9 @@ abstract class ViewMediaFragment : Fragment() {
protected const val ARG_ATTACHMENT = "attach"
@JvmStatic
protected val CONTROLS_TIMEOUT = 2.seconds // Consistent with YouTube player
/**
* @param attachment The media attachment to display in the fragment
* @param shouldCallMediaReady If true this fragment should call

View File

@ -47,14 +47,12 @@ import androidx.media3.exoplayer.util.EventLogger
import androidx.media3.ui.AspectRatioFrameLayout
import app.pachli.BuildConfig
import app.pachli.R
import app.pachli.ViewMediaActivity
import app.pachli.core.common.extensions.hide
import app.pachli.core.common.extensions.show
import app.pachli.core.common.extensions.viewBinding
import app.pachli.core.common.extensions.visible
import app.pachli.core.network.model.Attachment
import app.pachli.databinding.FragmentViewVideoBinding
import app.pachli.fragment.ViewVideoFragment.Companion.CONTROLS_TIMEOUT
import com.bumptech.glide.Glide
import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition
@ -62,9 +60,6 @@ import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlin.math.abs
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
@ -89,10 +84,6 @@ class ViewVideoFragment : ViewMediaFragment() {
private lateinit var toolbar: View
/** Hoist toolbar hiding to activity so it can track state across different fragments */
private var hideToolbarJob: Job? = null
private lateinit var mediaActivity: ViewMediaActivity
private lateinit var mediaPlayerListener: Player.Listener
private var isAudio = false
@ -116,7 +107,6 @@ class ViewVideoFragment : ViewMediaFragment() {
@SuppressLint("PrivateResource", "MissingInflatedId")
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
mediaActivity = activity as ViewMediaActivity
toolbar = mediaActivity.toolbar
val rootView = inflater.inflate(R.layout.fragment_view_video, container, false)
@ -243,7 +233,7 @@ class ViewVideoFragment : ViewMediaFragment() {
if (isPlaying) {
hideToolbarAfterDelay()
} else {
hideToolbarJob?.cancel()
cancelToolbarHide()
}
}
@ -301,7 +291,6 @@ class ViewVideoFragment : ViewMediaFragment() {
if (Build.VERSION.SDK_INT <= 23) {
binding.videoView.onPause()
releasePlayer()
hideToolbarJob?.cancel()
}
}
@ -313,7 +302,6 @@ class ViewVideoFragment : ViewMediaFragment() {
if (Build.VERSION.SDK_INT > 23) {
binding.videoView.onPause()
releasePlayer()
hideToolbarJob?.cancel()
}
}
@ -365,7 +353,10 @@ class ViewVideoFragment : ViewMediaFragment() {
}
@SuppressLint("ClickableViewAccessibility")
override fun setupMediaView(showingDescription: Boolean) {
override fun setupMediaView(
isToolbarVisible: Boolean,
showingDescription: Boolean,
) {
startedTransition = false
binding.mediaDescription.text = attachment.description
@ -389,15 +380,9 @@ class ViewVideoFragment : ViewMediaFragment() {
binding.videoView.requestFocus()
}
private fun hideToolbarAfterDelay() {
hideToolbarJob?.cancel()
hideToolbarJob = lifecycleScope.launch {
delay(CONTROLS_TIMEOUT)
mediaActivity.onMediaTap()
}
}
override fun onToolbarVisibilityChange(visible: Boolean) {
super.onToolbarVisibilityChange(visible)
if (!userVisibleHint) return
view ?: return
@ -420,16 +405,13 @@ class ViewVideoFragment : ViewMediaFragment() {
},
)
.start()
if (visible && (binding.videoView.player?.isPlaying == true) && !isAudio) {
hideToolbarAfterDelay()
} else {
hideToolbarJob?.cancel()
}
override fun shouldScheduleToolbarHide(): Boolean {
return (binding.videoView.player?.isPlaying == true) && !isAudio
}
companion object {
private val CONTROLS_TIMEOUT = 2.seconds // Consistent with YouTube player
private const val SEEK_POSITION = "seekPosition"
}
}