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.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageView import android.widget.ImageView
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.view.GestureDetectorCompat import androidx.core.view.GestureDetectorCompat
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import app.pachli.R import app.pachli.R
import app.pachli.ViewMediaActivity
import app.pachli.core.common.extensions.hide import app.pachli.core.common.extensions.hide
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
@ -41,6 +41,8 @@ import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.GlideException import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.request.RequestListener import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.target.Target 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.OnTouchCoordinatesListener
import com.ortiz.touchview.TouchImageView import com.ortiz.touchview.TouchImageView
import kotlin.math.abs import kotlin.math.abs
@ -50,24 +52,29 @@ class ViewImageFragment : ViewMediaFragment() {
private val binding by viewBinding(FragmentViewImageBinding::bind) 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 // 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. // immediately on another thread. Atomic is an overkill for such thing.
@Volatile @Volatile
private var startedTransition = false 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.photoView.transitionName = attachment.url
binding.mediaDescription.text = attachment.description binding.mediaDescription.text = attachment.description
binding.captionSheet.visible(showingDescription) binding.captionSheet.visible(showingDescription)
startedTransition = false startedTransition = false
loadImageFromNetwork(attachment.url, attachment.previewUrl, binding.photoView) 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 { 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) 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) { override fun onToolbarVisibilityChange(visible: Boolean) {
super.onToolbarVisibilityChange(visible)
if (!userVisibleHint) return if (!userVisibleHint) return
isDescriptionVisible = showingDescription && visible isDescriptionVisible = showingDescription && visible
@ -203,6 +220,15 @@ class ViewImageFragment : ViewMediaFragment() {
.start() .start()
} }
override fun shouldScheduleToolbarHide(): Boolean {
return if (scheduleToolbarHide) {
scheduleToolbarHide = false
true
} else {
false
}
}
override fun onStop() { override fun onStop() {
super.onStop() super.onStop()
Glide.with(this).clear(binding.photoView) Glide.with(this).clear(binding.photoView)

View File

@ -17,15 +17,22 @@
package app.pachli.fragment package app.pachli.fragment
import android.content.Context import android.content.Context
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.text.TextUtils import android.text.TextUtils
import android.view.View import android.view.View
import androidx.annotation.CallSuper
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import app.pachli.ViewMediaActivity import app.pachli.ViewMediaActivity
import app.pachli.core.network.model.Attachment import app.pachli.core.network.model.Attachment
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.CompletableDeferred 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 for actions that may happen while media is being displayed */
interface MediaActionsListener { interface MediaActionsListener {
@ -51,16 +58,56 @@ abstract class ViewMediaFragment : Fragment() {
* Called after [onResume], subclasses should override this and update * Called after [onResume], subclasses should override this and update
* the contents of views (including loading any media). * 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 * @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. * Called when the visibility of the toolbar changes.
* *
* @param visible True if the toolbar is visible * @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 showingDescription = false
protected var isDescriptionVisible = false protected var isDescriptionVisible = false
@ -82,6 +129,7 @@ abstract class ViewMediaFragment : Fragment() {
override fun onAttach(context: Context) { override fun onAttach(context: Context) {
super.onAttach(context) super.onAttach(context)
mediaActivity = activity as ViewMediaActivity
mediaActionsListener = context as MediaActionsListener mediaActionsListener = context as MediaActionsListener
} }
@ -109,18 +157,36 @@ abstract class ViewMediaFragment : Fragment() {
} }
private fun finalizeViewSetup() { private fun finalizeViewSetup() {
val mediaActivity = activity as ViewMediaActivity
showingDescription = !TextUtils.isEmpty(attachment.description) showingDescription = !TextUtils.isEmpty(attachment.description)
isDescriptionVisible = showingDescription isDescriptionVisible = showingDescription
setupMediaView(showingDescription && mediaActivity.isToolbarVisible) setupMediaView(mediaActivity.isToolbarVisible, showingDescription && mediaActivity.isToolbarVisible)
removeToolbarListener = (activity as ViewMediaActivity) removeToolbarListener = mediaActivity
.addToolbarVisibilityListener { isVisible -> .addToolbarVisibilityListener { isVisible ->
onToolbarVisibilityChange(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() { override fun onDestroyView() {
removeToolbarListener?.invoke() removeToolbarListener?.invoke()
transitionComplete = null transitionComplete = null
@ -132,6 +198,9 @@ abstract class ViewMediaFragment : Fragment() {
protected const val ARG_ATTACHMENT = "attach" 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 attachment The media attachment to display in the fragment
* @param shouldCallMediaReady If true this fragment should call * @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 androidx.media3.ui.AspectRatioFrameLayout
import app.pachli.BuildConfig import app.pachli.BuildConfig
import app.pachli.R import app.pachli.R
import app.pachli.ViewMediaActivity
import app.pachli.core.common.extensions.hide 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
import app.pachli.core.network.model.Attachment import app.pachli.core.network.model.Attachment
import app.pachli.databinding.FragmentViewVideoBinding import app.pachli.databinding.FragmentViewVideoBinding
import app.pachli.fragment.ViewVideoFragment.Companion.CONTROLS_TIMEOUT
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition import com.bumptech.glide.request.transition.Transition
@ -62,9 +60,6 @@ import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject import javax.inject.Inject
import kotlin.math.abs import kotlin.math.abs
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@ -89,10 +84,6 @@ class ViewVideoFragment : ViewMediaFragment() {
private lateinit var toolbar: View 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 lateinit var mediaPlayerListener: Player.Listener
private var isAudio = false private var isAudio = false
@ -116,7 +107,6 @@ class ViewVideoFragment : ViewMediaFragment() {
@SuppressLint("PrivateResource", "MissingInflatedId") @SuppressLint("PrivateResource", "MissingInflatedId")
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
mediaActivity = activity as ViewMediaActivity
toolbar = mediaActivity.toolbar toolbar = mediaActivity.toolbar
val rootView = inflater.inflate(R.layout.fragment_view_video, container, false) val rootView = inflater.inflate(R.layout.fragment_view_video, container, false)
@ -243,7 +233,7 @@ class ViewVideoFragment : ViewMediaFragment() {
if (isPlaying) { if (isPlaying) {
hideToolbarAfterDelay() hideToolbarAfterDelay()
} else { } else {
hideToolbarJob?.cancel() cancelToolbarHide()
} }
} }
@ -301,7 +291,6 @@ class ViewVideoFragment : ViewMediaFragment() {
if (Build.VERSION.SDK_INT <= 23) { if (Build.VERSION.SDK_INT <= 23) {
binding.videoView.onPause() binding.videoView.onPause()
releasePlayer() releasePlayer()
hideToolbarJob?.cancel()
} }
} }
@ -313,7 +302,6 @@ class ViewVideoFragment : ViewMediaFragment() {
if (Build.VERSION.SDK_INT > 23) { if (Build.VERSION.SDK_INT > 23) {
binding.videoView.onPause() binding.videoView.onPause()
releasePlayer() releasePlayer()
hideToolbarJob?.cancel()
} }
} }
@ -365,7 +353,10 @@ class ViewVideoFragment : ViewMediaFragment() {
} }
@SuppressLint("ClickableViewAccessibility") @SuppressLint("ClickableViewAccessibility")
override fun setupMediaView(showingDescription: Boolean) { override fun setupMediaView(
isToolbarVisible: Boolean,
showingDescription: Boolean,
) {
startedTransition = false startedTransition = false
binding.mediaDescription.text = attachment.description binding.mediaDescription.text = attachment.description
@ -389,15 +380,9 @@ class ViewVideoFragment : ViewMediaFragment() {
binding.videoView.requestFocus() binding.videoView.requestFocus()
} }
private fun hideToolbarAfterDelay() {
hideToolbarJob?.cancel()
hideToolbarJob = lifecycleScope.launch {
delay(CONTROLS_TIMEOUT)
mediaActivity.onMediaTap()
}
}
override fun onToolbarVisibilityChange(visible: Boolean) { override fun onToolbarVisibilityChange(visible: Boolean) {
super.onToolbarVisibilityChange(visible)
if (!userVisibleHint) return if (!userVisibleHint) return
view ?: return view ?: return
@ -420,16 +405,13 @@ class ViewVideoFragment : ViewMediaFragment() {
}, },
) )
.start() .start()
}
if (visible && (binding.videoView.player?.isPlaying == true) && !isAudio) { override fun shouldScheduleToolbarHide(): Boolean {
hideToolbarAfterDelay() return (binding.videoView.player?.isPlaying == true) && !isAudio
} else {
hideToolbarJob?.cancel()
}
} }
companion object { companion object {
private val CONTROLS_TIMEOUT = 2.seconds // Consistent with YouTube player
private const val SEEK_POSITION = "seekPosition" private const val SEEK_POSITION = "seekPosition"
} }
} }