Description improvements (#1846)

* Increase character limit for media descriptions to 1500

It was increased in Mastodon 3.0.0 which was released in October 2019.

* Improve image description view

Since media descriptions can be longer now, we need to adjust the UI.
It is a common problem that description takes up the whole screen, it's
hard for readers and also discourages people from adding descriptions.

This commit uses bottom sheet to hide most of the description. Since we
know how much screen space it will cover, we can use darker background
which makes reading text easier.

* Adjust description handle

* Fix unable to dismiss image caption
This commit is contained in:
Ivan Kupalov 2020-08-01 21:48:51 +02:00 committed by GitHub
parent be8fc9f15a
commit ed2918da2e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 140 additions and 81 deletions

View file

@ -753,7 +753,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
itemView.setAccessibilityDelegate(null); itemView.setAccessibilityDelegate(null);
} else { } else {
if (payloads instanceof List) if (payloads instanceof List)
for (Object item : (List) payloads) { for (Object item : (List<?>) payloads) {
if (Key.KEY_CREATED.equals(item)) { if (Key.KEY_CREATED.equals(item)) {
setCreatedAt(status.getCreatedAt(), statusDisplayOptions); setCreatedAt(status.getCreatedAt(), statusDisplayOptions);
} }

View file

@ -37,9 +37,8 @@ import com.github.chrisbanes.photoview.PhotoView
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.util.withLifecycleContext import com.keylesspalace.tusky.util.withLifecycleContext
// https://github.com/tootsuite/mastodon/blob/1656663/app/models/media_attachment.rb#L94 // https://github.com/tootsuite/mastodon/blob/c6904c0d3766a2ea8a81ab025c127169ecb51373/app/models/media_attachment.rb#L32
private const val MEDIA_DESCRIPTION_CHARACTER_LIMIT = 420 private const val MEDIA_DESCRIPTION_CHARACTER_LIMIT = 1500
fun <T> T.makeCaptionDialog(existingDescription: String?, fun <T> T.makeCaptionDialog(existingDescription: String?,
previewUri: Uri, previewUri: Uri,

View file

@ -21,9 +21,11 @@ import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.os.Bundle import android.os.Bundle
import android.view.* import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView import android.widget.ImageView
import android.widget.TextView
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.DataSource import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.GlideException import com.bumptech.glide.load.engine.GlideException
@ -57,16 +59,52 @@ class ViewImageFragment : ViewMediaFragment() {
@Volatile @Volatile
private var startedTransition = false private var startedTransition = false
override lateinit var descriptionView: TextView
override fun onAttach(context: Context) { override fun onAttach(context: Context) {
super.onAttach(context) super.onAttach(context)
photoActionsListener = context as PhotoActionsListener photoActionsListener = context as PhotoActionsListener
} }
@SuppressLint("ClickableViewAccessibility")
override fun setupMediaView(url: String, previewUrl: String?) { override fun setupMediaView(
descriptionView = mediaDescription url: String,
previewUrl: String?,
description: String?,
showingDescription: Boolean
) {
photoView.transitionName = url photoView.transitionName = url
mediaDescription.text = description
captionSheet.visible(showingDescription)
startedTransition = false
loadImageFromNetwork(url, previewUrl, photoView)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
toolbar = requireActivity().toolbar
this.transition = BehaviorSubject.create()
return inflater.inflate(R.layout.fragment_view_image, container, false)
}
@SuppressLint("ClickableViewAccessibility")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val arguments = this.requireArguments()
val attachment = arguments.getParcelable<Attachment>(ARG_ATTACHMENT)
this.shouldStartTransition = arguments.getBoolean(ARG_START_POSTPONED_TRANSITION)
val url: String?
var description: String? = null
if (attachment != null) {
url = attachment.url
description = attachment.description
} else {
url = arguments.getString(ARG_AVATAR_URL)
if (url == null) {
throw IllegalArgumentException("attachment or avatar url has to be set")
}
}
attacher = PhotoViewAttacher(photoView).apply { attacher = PhotoViewAttacher(photoView).apply {
// This prevents conflicts with ViewPager // This prevents conflicts with ViewPager
setAllowParentInterceptOnEdge(true) setAllowParentInterceptOnEdge(true)
@ -87,21 +125,12 @@ class ViewImageFragment : ViewMediaFragment() {
} }
} }
val gestureDetector = GestureDetector(requireContext(), object : GestureDetector.SimpleOnGestureListener() {
override fun onSingleTapConfirmed(e: MotionEvent?): Boolean {
onMediaTap()
return true
}
})
var lastY = 0f var lastY = 0f
photoView.setOnTouchListener { v, event -> photoView.setOnTouchListener { v, event ->
// This part is for scaling/translating on vertical move. // This part is for scaling/translating on vertical move.
// We use raw coordinates to get the correct ones during scaling // We use raw coordinates to get the correct ones during scaling
gestureDetector.onTouchEvent(event)
if (event.action == MotionEvent.ACTION_DOWN) { if (event.action == MotionEvent.ACTION_DOWN) {
lastY = event.rawY lastY = event.rawY
} else if (event.pointerCount == 1 } else if (event.pointerCount == 1
@ -125,36 +154,6 @@ class ViewImageFragment : ViewMediaFragment() {
attacher.onTouch(v, event) attacher.onTouch(v, event)
} }
startedTransition = false
loadImageFromNetwork(url, previewUrl, photoView)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
toolbar = requireActivity().toolbar
this.transition = BehaviorSubject.create()
return inflater.inflate(R.layout.fragment_view_image, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val arguments = this.requireArguments()
val attachment = arguments.getParcelable<Attachment>(ARG_ATTACHMENT)
this.shouldStartTransition = arguments.getBoolean(ARG_START_POSTPONED_TRANSITION)
val url: String?
var description: String? = null
if (attachment != null) {
url = attachment.url
description = attachment.description
} else {
url = arguments.getString(ARG_AVATAR_URL)
if (url == null) {
throw IllegalArgumentException("attachment or avatar url has to be set")
}
}
finalizeViewSetup(url, attachment?.previewUrl, description) finalizeViewSetup(url, attachment?.previewUrl, description)
} }
@ -176,10 +175,10 @@ class ViewImageFragment : ViewMediaFragment() {
} }
isDescriptionVisible = showingDescription && visible isDescriptionVisible = showingDescription && visible
val alpha = if (isDescriptionVisible) 1.0f else 0.0f val alpha = if (isDescriptionVisible) 1.0f else 0.0f
descriptionView.animate().alpha(alpha) captionSheet.animate().alpha(alpha)
.setListener(object : AnimatorListenerAdapter() { .setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) { override fun onAnimationEnd(animation: Animator) {
descriptionView.visible(isDescriptionVisible) captionSheet.visible(isDescriptionVisible)
animation.removeListener(this) animation.removeListener(this)
} }
}) })

View file

@ -17,18 +17,20 @@ package com.keylesspalace.tusky.fragment
import android.os.Bundle import android.os.Bundle
import android.text.TextUtils import android.text.TextUtils
import android.widget.TextView
import com.keylesspalace.tusky.ViewMediaActivity import com.keylesspalace.tusky.ViewMediaActivity
import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.util.visible
abstract class ViewMediaFragment : BaseFragment() { abstract class ViewMediaFragment : BaseFragment() {
private var toolbarVisibiltyDisposable: Function0<Boolean>? = null private var toolbarVisibiltyDisposable: Function0<Boolean>? = null
abstract fun setupMediaView(url: String, previewUrl: String?) abstract fun setupMediaView(
url: String,
previewUrl: String?,
description: String?,
showingDescription: Boolean
)
abstract fun onToolbarVisibilityChange(visible: Boolean) abstract fun onToolbarVisibilityChange(visible: Boolean)
abstract val descriptionView: TextView
protected var showingDescription = false protected var showingDescription = false
protected var isDescriptionVisible = false protected var isDescriptionVisible = false
@ -36,6 +38,7 @@ abstract class ViewMediaFragment : BaseFragment() {
companion object { companion object {
@JvmStatic @JvmStatic
protected val ARG_START_POSTPONED_TRANSITION = "startPostponedTransition" protected val ARG_START_POSTPONED_TRANSITION = "startPostponedTransition"
@JvmStatic @JvmStatic
protected val ARG_ATTACHMENT = "attach" protected val ARG_ATTACHMENT = "attach"
@JvmStatic @JvmStatic
@ -74,13 +77,10 @@ abstract class ViewMediaFragment : BaseFragment() {
protected fun finalizeViewSetup(url: String, previewUrl: String?, description: String?) { protected fun finalizeViewSetup(url: String, previewUrl: String?, description: String?) {
val mediaActivity = activity as ViewMediaActivity val mediaActivity = activity as ViewMediaActivity
setupMediaView(url, previewUrl)
descriptionView.text = description ?: ""
showingDescription = !TextUtils.isEmpty(description) showingDescription = !TextUtils.isEmpty(description)
isDescriptionVisible = showingDescription isDescriptionVisible = showingDescription
setupMediaView(url, previewUrl, description, showingDescription && mediaActivity.isToolbarVisible)
descriptionView.visible(showingDescription && mediaActivity.isToolbarVisible)
toolbarVisibiltyDisposable = (activity as ViewMediaActivity) toolbarVisibiltyDisposable = (activity as ViewMediaActivity)
.addToolbarVisibilityListener { isVisible -> .addToolbarVisibilityListener { isVisible ->

View file

@ -26,7 +26,6 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.MediaController import android.widget.MediaController
import android.widget.TextView
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.ViewMediaActivity import com.keylesspalace.tusky.ViewMediaActivity
import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Attachment
@ -47,7 +46,6 @@ class ViewVideoFragment : ViewMediaFragment() {
} }
private lateinit var mediaActivity: ViewMediaActivity private lateinit var mediaActivity: ViewMediaActivity
private val TOOLBAR_HIDE_DELAY_MS = 3000L private val TOOLBAR_HIDE_DELAY_MS = 3000L
override lateinit var descriptionView : TextView
private lateinit var mediaController : MediaController private lateinit var mediaController : MediaController
private var isAudio = false private var isAudio = false
@ -71,8 +69,14 @@ class ViewVideoFragment : ViewMediaFragment() {
} }
@SuppressLint("ClickableViewAccessibility") @SuppressLint("ClickableViewAccessibility")
override fun setupMediaView(url: String, previewUrl: String?) { override fun setupMediaView(
descriptionView = mediaDescription url: String,
previewUrl: String?,
description: String?,
showingDescription: Boolean
) {
mediaDescription.text = description
mediaDescription.visible(showingDescription)
videoView.transitionName = url videoView.transitionName = url
videoView.setVideoPath(url) videoView.setVideoPath(url)
@ -178,14 +182,14 @@ class ViewVideoFragment : ViewMediaFragment() {
val alpha = if (isDescriptionVisible) 1.0f else 0.0f val alpha = if (isDescriptionVisible) 1.0f else 0.0f
if (isDescriptionVisible) { if (isDescriptionVisible) {
// If to be visible, need to make visible immediately and animate alpha // If to be visible, need to make visible immediately and animate alpha
descriptionView.alpha = 0.0f mediaDescription.alpha = 0.0f
descriptionView.visible(isDescriptionVisible) mediaDescription.visible(isDescriptionVisible)
} }
descriptionView.animate().alpha(alpha) mediaDescription.animate().alpha(alpha)
.setListener(object : AnimatorListenerAdapter() { .setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) { override fun onAnimationEnd(animation: Animator) {
descriptionView.visible(isDescriptionVisible) mediaDescription.visible(isDescriptionVisible)
animation.removeListener(this) animation.removeListener(this)
} }
}) })

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners
android:topLeftRadius="6dp"
android:topRightRadius="6dp" />
<stroke
android:color="?attr/colorBackgroundAccent"
android:width="1dp" />
<solid android:color="#B3000000" />
</shape>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape android:shape="rectangle"
xmlns:android="http://schemas.android.com/apk/res/android">
<size
android:width="8dp"
android:height="1dp" />
<solid android:color="?attr/colorBackgroundAccent" />
<corners android:radius="1dp" />
</shape>

View file

@ -24,18 +24,56 @@
app:layout_constraintRight_toRightOf="parent" app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" /> app:layout_constraintTop_toTopOf="parent" />
<!-- This should be inside CoordinatorLayout for two reasons:
1. TouchImageView really wants some constraints ans has no size otherwise
2. We don't want sheet to overlap with appbar but the only way to do it with autosizing
is to gibe parent some margin. -->
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="70dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent">
<LinearLayout
android:id="@+id/captionSheet"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/description_bg_expanded"
android:orientation="vertical"
app:behavior_peekHeight="90dp"
app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior">
<View
android:layout_width="24dp"
android:layout_height="3dp"
android:layout_marginTop="10dp"
android:layout_marginBottom="10dp"
android:layout_gravity="center_horizontal"
android:importantForAccessibility="no"
android:background="@drawable/ic_drag_indicator_horiz_24dp" />
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView <TextView
android:id="@+id/mediaDescription" android:id="@+id/mediaDescription"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:background="#60000000"
android:hyphenationFrequency="full" android:hyphenationFrequency="full"
android:lineSpacingMultiplier="1.1" android:lineSpacingMultiplier="1.1"
android:padding="8dp" android:paddingLeft="8dp"
android:paddingRight="8dp"
android:paddingBottom="8dp"
android:textAlignment="center" android:textAlignment="center"
android:textColor="#eee" android:textColor="#eee"
android:textSize="?attr/status_text_medium" android:textSize="?attr/status_text_medium"
app:layout_constraintBottom_toBottomOf="parent" tools:text="Some media description which might get quite long so that it won't easily fit in one line" />
tools:text="Some media description" /> </androidx.core.widget.NestedScrollView>
</LinearLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>