ViewImageFragment: replace TouchImageView by BigImageView based on SSIV and with proper GIF support

main
Alibek Omarov 4 years ago
parent c6cfdcc3b1
commit 978489165e
  1. 4
      app/build.gradle
  2. 4
      app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt
  3. 21
      app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt
  4. 219
      app/src/main/java/com/keylesspalace/tusky/fragment/ViewImageFragment.kt
  5. 6
      app/src/main/res/layout/fragment_view_image.xml
  6. 1
      app/src/main/res/menu/account_toolbar.xml
  7. 7
      build.gradle

@ -178,7 +178,9 @@ dependencies {
implementation "com.github.connyduck:sparkbutton:4.0.0" implementation "com.github.connyduck:sparkbutton:4.0.0"
implementation 'com.github.MikeOrtiz:TouchImageView:3.0.1' implementation 'com.github.piasy:BigImageViewer:1.6.5'
implementation 'com.github.piasy:GlideImageLoader:1.6.5'
implementation 'com.github.piasy:GlideImageViewFactory:1.6.5'
implementation "com.mikepenz:materialdrawer:$materialdrawerVersion" implementation "com.mikepenz:materialdrawer:$materialdrawerVersion"
implementation "com.mikepenz:materialdrawer-iconics:$materialdrawerVersion" implementation "com.mikepenz:materialdrawer-iconics:$materialdrawerVersion"

@ -22,6 +22,8 @@ import android.util.Log
import androidx.emoji.text.EmojiCompat import androidx.emoji.text.EmojiCompat
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import androidx.work.WorkManager import androidx.work.WorkManager
import com.github.piasy.biv.BigImageViewer
import com.github.piasy.biv.loader.glide.GlideCustomImageLoader
import com.keylesspalace.tusky.components.notifications.NotificationWorkerFactory import com.keylesspalace.tusky.components.notifications.NotificationWorkerFactory
import com.keylesspalace.tusky.di.AppInjector import com.keylesspalace.tusky.di.AppInjector
import com.keylesspalace.tusky.util.* import com.keylesspalace.tusky.util.*
@ -73,6 +75,8 @@ class TuskyApplication : Application(), HasAndroidInjector {
RxJavaPlugins.setErrorHandler { RxJavaPlugins.setErrorHandler {
Log.w("RxJava", "undeliverable exception", it) Log.w("RxJava", "undeliverable exception", it)
} }
BigImageViewer.initialize(GlideCustomImageLoader.with(this))
} }
override fun attachBaseContext(base: Context) { override fun attachBaseContext(base: Context) {

@ -33,9 +33,11 @@ import at.connyduck.sparkbutton.helpers.Utils
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
import com.github.piasy.biv.loader.glide.GlideCustomImageLoader
import com.github.piasy.biv.view.BigImageView
import com.github.piasy.biv.view.GlideImageViewFactory
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.util.withLifecycleContext import com.keylesspalace.tusky.util.withLifecycleContext
import com.ortiz.touchview.TouchImageView
// https://github.com/tootsuite/mastodon/blob/1656663/app/models/media_attachment.rb#L94 // https://github.com/tootsuite/mastodon/blob/1656663/app/models/media_attachment.rb#L94
private const val MEDIA_DESCRIPTION_CHARACTER_LIMIT = 420 private const val MEDIA_DESCRIPTION_CHARACTER_LIMIT = 420
@ -50,9 +52,9 @@ fun <T> T.makeCaptionDialog(existingDescription: String?,
dialogLayout.setPadding(padding, padding, padding, padding) dialogLayout.setPadding(padding, padding, padding, padding)
dialogLayout.orientation = LinearLayout.VERTICAL dialogLayout.orientation = LinearLayout.VERTICAL
val imageView = TouchImageView(this).apply { val imageView = BigImageView(this)
maxZoom = 6f // imageView.ssiv.maxScale = 6f
} imageView.setImageViewFactory(GlideImageViewFactory())
val displayMetrics = DisplayMetrics() val displayMetrics = DisplayMetrics()
windowManager.defaultDisplay.getMetrics(displayMetrics) windowManager.defaultDisplay.getMetrics(displayMetrics)
@ -98,18 +100,9 @@ fun <T> T.makeCaptionDialog(existingDescription: String?,
// Load the image and manually set it into the ImageView because it doesn't have a fixed // Load the image and manually set it into the ImageView because it doesn't have a fixed
// size. Maybe we should limit the size of CustomTarget // size. Maybe we should limit the size of CustomTarget
Glide.with(this) imageView.showImage(previewUri)
.load(previewUri)
.into(object : CustomTarget<Drawable>() {
override fun onLoadCleared(placeholder: Drawable?) {}
override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) {
imageView.setImageDrawable(resource)
}
})
} }
private fun Activity.showFailedCaptionMessage() { private fun Activity.showFailedCaptionMessage() {
Toast.makeText(this, R.string.error_failed_set_caption, Toast.LENGTH_SHORT).show() Toast.makeText(this, R.string.error_failed_set_caption, Toast.LENGTH_SHORT).show()
} }

@ -20,15 +20,18 @@ import android.animation.AnimatorListenerAdapter
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.util.Log
import android.view.* import android.view.*
import android.widget.ImageView
import android.widget.TextView 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
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.github.piasy.biv.loader.ImageLoader
import com.github.piasy.biv.view.GlideImageViewFactory
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.hide
@ -36,9 +39,12 @@ import com.keylesspalace.tusky.util.visible
import io.reactivex.subjects.BehaviorSubject import io.reactivex.subjects.BehaviorSubject
import kotlinx.android.synthetic.main.activity_view_media.* import kotlinx.android.synthetic.main.activity_view_media.*
import kotlinx.android.synthetic.main.fragment_view_image.* import kotlinx.android.synthetic.main.fragment_view_image.*
import java.io.File
import java.lang.Exception
import kotlin.math.abs import kotlin.math.abs
class ViewImageFragment : ViewMediaFragment() {
class ViewImageFragment : ViewMediaFragment(), ImageLoader.Callback, View.OnTouchListener {
interface PhotoActionsListener { interface PhotoActionsListener {
fun onBringUp() fun onBringUp()
fun onDismiss() fun onDismiss()
@ -47,7 +53,6 @@ class ViewImageFragment : ViewMediaFragment() {
private lateinit var photoActionsListener: PhotoActionsListener private lateinit var photoActionsListener: PhotoActionsListener
private lateinit var toolbar: View private lateinit var toolbar: View
private var transition = BehaviorSubject.create<Unit>()
private var shouldStartTransition = false private var shouldStartTransition = false
// 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
@ -65,74 +70,74 @@ class ViewImageFragment : ViewMediaFragment() {
descriptionView = mediaDescription descriptionView = mediaDescription
photoView.transitionName = url photoView.transitionName = url
startedTransition = false startedTransition = false
loadImageFromNetwork(url, previewUrl, photoView) loadImageFromNetwork(url, previewUrl)
} }
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
toolbar = activity!!.toolbar toolbar = activity!!.toolbar
this.transition = BehaviorSubject.create()
return inflater.inflate(R.layout.fragment_view_image, container, false) return inflater.inflate(R.layout.fragment_view_image, container, false)
} }
@SuppressLint("ClickableViewAccessibility") private var lastY = 0.0f
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { private var swipeStartedWithOneFinger = false
super.onViewCreated(view, savedInstanceState) private lateinit var gestureDetector : GestureDetector
val gestureDetector = GestureDetector(requireContext(), object : GestureDetector.SimpleOnGestureListener() { override fun onTouch(v: View, event: MotionEvent): Boolean {
override fun onSingleTapConfirmed(e: MotionEvent?): Boolean { // This part is for scaling/translating on vertical move.
onMediaTap() // We use raw coordinates to get the correct ones during scaling
return true gestureDetector.onTouchEvent(event)
}
})
var lastY = 0f if(event.pointerCount != 1) {
photoView.setOnTouchListener { _, event -> swipeStartedWithOneFinger = false
// This part is for scaling/translating on vertical move. return false
// We use raw coordinates to get the correct ones during scaling }
var result = true
gestureDetector.onTouchEvent(event) var result = false
if (event.action == MotionEvent.ACTION_DOWN) { when(event.action) {
MotionEvent.ACTION_DOWN -> {
swipeStartedWithOneFinger = true
lastY = event.rawY lastY = event.rawY
} else if (!photoView.isZoomed && event.action == MotionEvent.ACTION_MOVE) { }
val diff = event.rawY - lastY MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
// This code is to prevent transformations during page scrolling
// If we are already translating or we reached the threshold, then transform.
if (photoView.translationY != 0f || abs(diff) > 40) {
photoView.translationY += (diff)
val scale = (-abs(photoView.translationY) / 720 + 1).coerceAtLeast(0.5f)
photoView.scaleY = scale
photoView.scaleX = scale
lastY = event.rawY
}
return@setOnTouchListener true
} else if (event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL) {
onGestureEnd() onGestureEnd()
} else if (event.pointerCount >= 2 || photoView.canScrollHorizontally(1) && photoView.canScrollHorizontally(-1)) { swipeStartedWithOneFinger = false
// Starting from here is adapted code from TouchImageView to play nice with pager. }
MotionEvent.ACTION_MOVE -> {
// Can scroll horizontally checks if there's still a part of the image. if(swipeStartedWithOneFinger && photoView.ssiv.scale <= photoView.ssiv.minScale) {
// That can be scrolled until you reach the edge multi-touch event. val diff = event.rawY - lastY
val parent = view.parent // This code is to prevent transformations during page scrolling
result = when (event.action) { // If we are already translating or we reached the threshold, then transform.
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> { if (photoView.translationY != 0f || abs(diff) > 40) {
// Disallow RecyclerView to intercept touch events. photoView.translationY += (diff)
parent.requestDisallowInterceptTouchEvent(true) val scale = (-abs(photoView.translationY) / 720 + 1).coerceAtLeast(0.5f)
// Disable touch on view photoView.scaleY = scale
false photoView.scaleX = scale
} lastY = event.rawY
MotionEvent.ACTION_UP -> {
// Allow RecyclerView to intercept touch events.
parent.requestDisallowInterceptTouchEvent(false)
true
} }
else -> true result = true
} }
} }
result
} }
return result
}
@SuppressLint("ClickableViewAccessibility")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
gestureDetector = GestureDetector(requireContext(), object : GestureDetector.SimpleOnGestureListener() {
override fun onSingleTapConfirmed(e: MotionEvent?): Boolean {
onMediaTap()
return true
}
})
// photoView.setOnTouchListener(this)
photoView.setImageLoaderCallback(this)
photoView.setImageViewFactory(GlideImageViewFactory())
val arguments = this.requireArguments() val arguments = this.requireArguments()
val attachment = arguments.getParcelable<Attachment>(ARG_ATTACHMENT) val attachment = arguments.getParcelable<Attachment>(ARG_ATTACHMENT)
this.shouldStartTransition = arguments.getBoolean(ARG_START_POSTPONED_TRANSITION) this.shouldStartTransition = arguments.getBoolean(ARG_START_POSTPONED_TRANSITION)
@ -181,99 +186,37 @@ class ViewImageFragment : ViewMediaFragment() {
} }
override fun onDestroyView() { override fun onDestroyView() {
Glide.with(this).clear(photoView)
transition.onComplete()
super.onDestroyView() super.onDestroyView()
photoView.ssiv?.recycle()
} }
private fun loadImageFromNetwork(url: String, previewUrl: String?, photoView: ImageView) { private fun loadImageFromNetwork(url: String, previewUrl: String?) {
val glide = Glide.with(this) photoView.showImage(Uri.parse(previewUrl), Uri.parse(url))
// Request image from the any cache
glide
.load(url)
.dontAnimate()
.onlyRetrieveFromCache(true)
.let {
if (previewUrl != null)
it.thumbnail(glide
.load(previewUrl)
.dontAnimate()
.onlyRetrieveFromCache(true)
.addListener(ImageRequestListener(true, isThumnailRequest = true)))
else it
}
//Request image from the network on fail load image from cache
.error(glide.load(url)
.centerInside()
.addListener(ImageRequestListener(false, isThumnailRequest = false))
)
.addListener(ImageRequestListener(true, isThumnailRequest = false))
.into(photoView)
} }
/** override fun onSuccess(image: File?) {
* We start transition as soon as we think reasonable but we must take care about couple of progressBar?.hide() // Always hide the progress bar on success
* things> photoActionsListener.onBringUp()
* - Do not change image in the middle of transition. It messes up the view. photoView.ssiv?.setOnTouchListener(this)
* - Do not transition for the views which don't require it. Starting transition from }
* multiple fragments does weird things
* - Do not wait to transition until the image loads from network
*
* Preview, cached image, network image, x - failed, o - succeeded
* P C N - start transition after...
* x x x - the cache fails
* x x o - the cache fails
* x o o - the cache succeeds
* o x o - the preview succeeds. Do not start on cache.
* o o o - the preview succeeds. Do not start on cache.
*
* So start transition after the first success or after anything with the cache
*
* @param isCacheRequest - is this listener for request image from cache or from the network
*/
private inner class ImageRequestListener(
private val isCacheRequest: Boolean,
private val isThumnailRequest: Boolean) : RequestListener<Drawable> {
override fun onLoadFailed(e: GlideException?, model: Any, target: Target<Drawable>,
isFirstResource: Boolean): Boolean {
// If cache for full image failed complete transition
if (isCacheRequest && !isThumnailRequest && shouldStartTransition
&& !startedTransition) {
photoActionsListener.onBringUp()
}
// Hide progress bar only on fail request from internet
if (!isCacheRequest) progressBar?.hide()
// We don't want to overwrite preview with null when main image fails to load
return !isCacheRequest
}
@SuppressLint("CheckResult") override fun onFail(error: Exception?) {
override fun onResourceReady(resource: Drawable, model: Any, target: Target<Drawable>, progressBar?.hide()
dataSource: DataSource, isFirstResource: Boolean): Boolean { photoActionsListener.onBringUp()
progressBar?.hide() // Always hide the progress bar on success }
if (!startedTransition || !shouldStartTransition) { override fun onCacheHit(imageType: Int, image: File?) {
// Set this right away so that we don't have to concurrent post() requests }
startedTransition = true
// post() because load() replaces image with null. Sometimes after we set override fun onCacheMiss(imageType: Int, image: File?) {
// the thumbnail. }
photoView.post {
target.onResourceReady(resource, null) override fun onFinish() {
if (shouldStartTransition) photoActionsListener.onBringUp() }
}
} else { override fun onProgress(progress: Int) {
// This wait for transition. If there's no transition then we should hit
// another branch. take() will unsubscribe after we have it to not leak menmory
transition
.take(1)
.subscribe { target.onResourceReady(resource, null) }
}
return true
}
} }
override fun onTransitionEnd() { override fun onTransitionEnd() {
this.transition.onNext(Unit)
} }
} }

@ -7,10 +7,12 @@
android:clickable="true" android:clickable="true"
android:focusable="true"> android:focusable="true">
<com.ortiz.touchview.TouchImageView <com.github.piasy.biv.view.BigImageView
android:id="@+id/photoView" android:id="@+id/photoView"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" /> android:layout_height="match_parent"
app:initScaleType="fitCenter"
app:optimizeDisplay="false" />
<ProgressBar <ProgressBar
android:id="@+id/progressBar" android:id="@+id/progressBar"

@ -4,6 +4,7 @@
<item android:id="@+id/action_open_in_web" <item android:id="@+id/action_open_in_web"
android:icon="@drawable/ic_exit_to_app_24px" android:icon="@drawable/ic_exit_to_app_24px"
android:iconTint="@color/textColorPrimary"
android:title="@string/action_open_in_web" android:title="@string/action_open_in_web"
app:showAsAction="always" /> app:showAsAction="always" />

@ -14,7 +14,12 @@ allprojects {
repositories { repositories {
google() google()
jcenter() jcenter()
maven { url "https://jitpack.io" } maven {
url "http://dl.bintray.com/piasy/maven"
}
maven {
url "https://jitpack.io"
}
} }
} }

Loading…
Cancel
Save