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:
parent
0445e187df
commit
bdf2d9329e
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue