From bdf2d9329e0d96a08e7c3fb40c56c05d2627425d Mon Sep 17 00:00:00 2001 From: Angelo Suzuki <1063155+tinsukE@users.noreply.github.com> Date: Sun, 10 Mar 2024 23:13:40 +0100 Subject: [PATCH] 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 --- .../app/pachli/fragment/ViewImageFragment.kt | 36 +++++++-- .../app/pachli/fragment/ViewMediaFragment.kt | 81 +++++++++++++++++-- .../app/pachli/fragment/ViewVideoFragment.kt | 38 +++------ 3 files changed, 116 insertions(+), 39 deletions(-) diff --git a/app/src/main/java/app/pachli/fragment/ViewImageFragment.kt b/app/src/main/java/app/pachli/fragment/ViewImageFragment.kt index 3b19e35de..8974836a7 100644 --- a/app/src/main/java/app/pachli/fragment/ViewImageFragment.kt +++ b/app/src/main/java/app/pachli/fragment/ViewImageFragment.kt @@ -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) diff --git a/app/src/main/java/app/pachli/fragment/ViewMediaFragment.kt b/app/src/main/java/app/pachli/fragment/ViewMediaFragment.kt index 2c94f9c09..31d355cb7 100644 --- a/app/src/main/java/app/pachli/fragment/ViewMediaFragment.kt +++ b/app/src/main/java/app/pachli/fragment/ViewMediaFragment.kt @@ -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 diff --git a/app/src/main/java/app/pachli/fragment/ViewVideoFragment.kt b/app/src/main/java/app/pachli/fragment/ViewVideoFragment.kt index 0a98cf5a1..e46784ad0 100644 --- a/app/src/main/java/app/pachli/fragment/ViewVideoFragment.kt +++ b/app/src/main/java/app/pachli/fragment/ViewVideoFragment.kt @@ -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" } }