Skip to content

Commit

Permalink
feat: Auto-hide Image Viewer toolbar
Browse files Browse the repository at this point in the history
When viewing an Image, it is not straightforward to understand that tapping on it will hide the toolbar and alt sheet, and those elements might get in the way of them viewing the full image.

Automatically hiding the toolbar and alt sheet after some short time might help the user two ways:
1. Get out of their way when they're viewing an image with an aspect ratio so that those elements are on top of it.
2. Showing them that those elements are hide-able, which might nudge into tapping to restore them and, consequently, learn about the "tap to hide/show" feature.
  • Loading branch information
tinsukE committed Mar 7, 2024
1 parent 32a6d34 commit 8d70cf0
Show file tree
Hide file tree
Showing 3 changed files with 116 additions and 39 deletions.
36 changes: 31 additions & 5 deletions app/src/main/java/app/pachli/fragment/ViewImageFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)
}

Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
81 changes: 75 additions & 6 deletions app/src/main/java/app/pachli/fragment/ViewMediaFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
mediaActivity.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
Expand All @@ -82,6 +129,7 @@ abstract class ViewMediaFragment : Fragment() {

override fun onAttach(context: Context) {
super.onAttach(context)
mediaActivity = activity as ViewMediaActivity
mediaActionsListener = context as MediaActionsListener
}

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
38 changes: 10 additions & 28 deletions app/src/main/java/app/pachli/fragment/ViewVideoFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -47,24 +47,19 @@ 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
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

Expand All @@ -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

Expand All @@ -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)

Expand Down Expand Up @@ -243,7 +233,7 @@ class ViewVideoFragment : ViewMediaFragment() {
if (isPlaying) {
hideToolbarAfterDelay()
} else {
hideToolbarJob?.cancel()
cancelToolbarHide()
}
}

Expand Down Expand Up @@ -301,7 +291,6 @@ class ViewVideoFragment : ViewMediaFragment() {
if (Build.VERSION.SDK_INT <= 23) {
binding.videoView.onPause()
releasePlayer()
hideToolbarJob?.cancel()
}
}

Expand All @@ -313,7 +302,6 @@ class ViewVideoFragment : ViewMediaFragment() {
if (Build.VERSION.SDK_INT > 23) {
binding.videoView.onPause()
releasePlayer()
hideToolbarJob?.cancel()
}
}

Expand Down Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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"
}
}

0 comments on commit 8d70cf0

Please sign in to comment.