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.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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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) {
|
|
||||||
hideToolbarAfterDelay()
|
|
||||||
} else {
|
|
||||||
hideToolbarJob?.cancel()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun shouldScheduleToolbarHide(): Boolean {
|
||||||
|
return (binding.videoView.player?.isPlaying == true) && !isAudio
|
||||||
}
|
}
|
||||||
|
|
||||||
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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue