Add thumbnail shared element animation.

This commit is contained in:
Alex Hart
2023-02-09 14:38:48 -04:00
committed by Greyson Parrelli
parent 2c48d40375
commit d0de43a6b2
25 changed files with 484 additions and 252 deletions

View File

@@ -7,16 +7,22 @@ import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.Nullable;
import androidx.lifecycle.ViewModelProvider;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.ZoomingImageView;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.util.LifecycleDisposable;
import org.thoughtcrime.securesms.util.MediaUtil;
public final class ImageMediaPreviewFragment extends MediaPreviewFragment {
private MediaPreviewPlayerControlView bottomBarControlView;
private MediaPreviewV2ViewModel viewModel;
private LifecycleDisposable lifecycleDisposable;
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
@@ -24,12 +30,19 @@ public final class ImageMediaPreviewFragment extends MediaPreviewFragment {
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
ZoomingImageView zoomingImageView = (ZoomingImageView) inflater.inflate(R.layout.media_preview_image_fragment, container, false);
Bundle savedInstanceState)
{
View view = inflater.inflate(R.layout.media_preview_image_fragment, container, false);
GlideRequests glideRequests = GlideApp.with(requireActivity());
Bundle arguments = requireArguments();
Uri uri = arguments.getParcelable(DATA_URI);
String contentType = arguments.getString(DATA_CONTENT_TYPE);
ZoomingImageView zoomingImageView = view.findViewById(R.id.zooming_image_view);
viewModel = new ViewModelProvider(requireActivity()).get(MediaPreviewV2ViewModel.class);
lifecycleDisposable = new LifecycleDisposable();
lifecycleDisposable.bindTo(getViewLifecycleOwner());
if (!MediaUtil.isImageType(contentType)) {
throw new AssertionError("This fragment can only display images");
@@ -40,7 +53,11 @@ public final class ImageMediaPreviewFragment extends MediaPreviewFragment {
zoomingImageView.setOnClickListener(v -> events.singleTapOnMedia());
return zoomingImageView;
lifecycleDisposable.add(viewModel.getState().distinctUntilChanged().subscribe(state -> {
zoomingImageView.setVisibility(state.isInSharedAnimation() ? View.INVISIBLE : View.VISIBLE);
}));
return view;
}
@Override

View File

@@ -6,6 +6,7 @@ import android.net.Uri
import android.os.Bundle
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import org.signal.core.util.dp
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.database.MediaTable
import org.thoughtcrime.securesms.database.MediaTable.MediaRecord
@@ -15,15 +16,16 @@ object MediaIntentFactory {
const val NOT_IN_A_THREAD = -2
const val UNKNOWN_TIMESTAMP = -2
const val THREAD_ID_EXTRA = "thread_id"
const val DATE_EXTRA = "date"
const val SIZE_EXTRA = "size"
const val CAPTION_EXTRA = "caption"
const val LEFT_IS_RECENT_EXTRA = "left_is_recent"
const val HIDE_ALL_MEDIA_EXTRA = "came_from_all_media"
const val SHOW_THREAD_EXTRA = "show_thread"
const val SORTING_EXTRA = "sorting"
const val IS_VIDEO_GIF = "is_video_gif"
@Parcelize
data class SharedElementArgs(
val width: Int = 1,
val height: Int = 1,
val topLeft: Float = 0f,
val topRight: Float = 0f,
val bottomRight: Float = 0f,
val bottomLeft: Float = 0f
) : Parcelable
@Parcelize
data class MediaPreviewArgs(
@@ -38,7 +40,8 @@ object MediaIntentFactory {
val showThread: Boolean = false,
val allMediaInRail: Boolean = false,
val sorting: MediaTable.Sorting,
val isVideoGif: Boolean
val isVideoGif: Boolean,
val sharedElementArgs: SharedElementArgs = SharedElementArgs()
) : Parcelable
@JvmStatic
@@ -68,7 +71,15 @@ object MediaIntentFactory {
leftIsRecent,
allMediaInRail = allMediaInRail,
sorting = MediaTable.Sorting.Newest,
isVideoGif = attachment.isVideoGif
isVideoGif = attachment.isVideoGif,
sharedElementArgs = SharedElementArgs(
attachment.width,
attachment.height,
12.dp.toFloat(),
12.dp.toFloat(),
12.dp.toFloat(),
12.dp.toFloat()
)
)
)
}

View File

@@ -1,28 +1,104 @@
package org.thoughtcrime.securesms.mediapreview
import android.content.Context
import android.net.Uri
import android.os.Bundle
import android.view.View
import android.widget.ImageView
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.content.ContextCompat
import androidx.core.transition.addListener
import androidx.core.view.animation.PathInterpolatorCompat
import androidx.fragment.app.commit
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.google.android.material.shape.ShapeAppearanceModel
import com.google.android.material.transition.platform.MaterialContainerTransform
import com.google.android.material.transition.platform.MaterialContainerTransformSharedElementCallback
import org.thoughtcrime.securesms.PassphraseRequiredActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.util.ActionRequestListener
import org.thoughtcrime.securesms.util.LifecycleDisposable
class MediaPreviewV2Activity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner {
override lateinit var voiceNoteMediaController: VoiceNoteMediaController
private val viewModel: MediaPreviewV2ViewModel by viewModels()
private val lifecycleDisposable = LifecycleDisposable()
private lateinit var transitionImageView: ImageView
override fun attachBaseContext(newBase: Context) {
delegate.localNightMode = AppCompatDelegate.MODE_NIGHT_YES
super.attachBaseContext(newBase)
}
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
val args = MediaIntentFactory.requireArguments(intent.extras!!)
val originalCorners = ShapeAppearanceModel.Builder()
.setTopLeftCornerSize(args.sharedElementArgs.topLeft)
.setTopRightCornerSize(args.sharedElementArgs.topRight)
.setBottomRightCornerSize(args.sharedElementArgs.bottomRight)
.setBottomLeftCornerSize(args.sharedElementArgs.bottomLeft)
.build()
postponeEnterTransition()
setEnterSharedElementCallback(MaterialContainerTransformSharedElementCallback())
window.sharedElementEnterTransition = MaterialContainerTransform().apply {
addTarget(SHARED_ELEMENT_TRANSITION_NAME)
startShapeAppearanceModel = originalCorners
endShapeAppearanceModel = ShapeAppearanceModel.builder().setAllCornerSizes(0f).build()
duration = 250L
interpolator = PathInterpolatorCompat.create(0.17f, 0.17f, 0f, 1f)
addListener(
onStart = {
transitionImageView.visibility = View.VISIBLE
viewModel.setIsInSharedAnimation(true)
},
onEnd = {
transitionImageView.clearAnimation()
transitionImageView.visibility = View.INVISIBLE
viewModel.setIsInSharedAnimation(false)
}
)
}
window.sharedElementExitTransition = MaterialContainerTransform().apply {
addTarget(SHARED_ELEMENT_TRANSITION_NAME)
startShapeAppearanceModel = ShapeAppearanceModel.builder().setAllCornerSizes(0f).build()
endShapeAppearanceModel = originalCorners
duration = 250L
interpolator = PathInterpolatorCompat.create(0.17f, 0.17f, 0f, 1f)
addListener(
onStart = {
transitionImageView.visibility = View.VISIBLE
viewModel.setIsInSharedAnimation(true)
},
onEnd = {
transitionImageView.clearAnimation()
transitionImageView.visibility = View.INVISIBLE
viewModel.setIsInSharedAnimation(false)
}
)
}
super.onCreate(savedInstanceState, ready)
setTheme(R.style.TextSecure_MediaPreview)
setContentView(R.layout.activity_mediapreview_v2)
transitionImageView = findViewById(R.id.transition_image_view)
lifecycleDisposable += viewModel.state.subscribe { state ->
if (state.position in state.mediaRecords.indices) {
setTransitionImage(state.mediaRecords[state.position].attachment?.uri)
}
}
voiceNoteMediaController = VoiceNoteMediaController(this)
val systemBarColor = ContextCompat.getColor(this, R.color.signal_dark_colorSurface)
@@ -31,7 +107,6 @@ class MediaPreviewV2Activity : PassphraseRequiredActivity(), VoiceNoteMediaContr
if (savedInstanceState == null) {
val bundle = Bundle()
val args = MediaIntentFactory.requireArguments(intent.extras!!)
bundle.putParcelable(MediaPreviewV2Fragment.ARGS_KEY, args)
supportFragmentManager.commit {
setReorderingAllowed(true)
@@ -40,7 +115,23 @@ class MediaPreviewV2Activity : PassphraseRequiredActivity(), VoiceNoteMediaContr
}
}
private fun setTransitionImage(mediaUri: Uri?) {
if (mediaUri == null) {
GlideApp.with(this).clear(transitionImageView)
return
}
GlideApp.with(this)
.load(DecryptableStreamUriLoader.DecryptableUri(mediaUri))
.diskCacheStrategy(DiskCacheStrategy.NONE)
.dontTransform()
.downsample(DownsampleStrategy.FIT_CENTER)
.addListener(ActionRequestListener.onEither { startPostponedEnterTransition() })
.into(transitionImageView)
}
companion object {
private const val FRAGMENT_TAG = "media_preview_fragment_v2"
const val SHARED_ELEMENT_TRANSITION_NAME = "thumb"
}
}

View File

@@ -79,7 +79,9 @@ class MediaPreviewV2Fragment : LoggingFragment(R.layout.fragment_media_preview_v
private val lifecycleDisposable = LifecycleDisposable()
private val binding by ViewBinderDelegate(FragmentMediaPreviewV2Binding::bind)
private val viewModel: MediaPreviewV2ViewModel by viewModels()
private val viewModel: MediaPreviewV2ViewModel by viewModels(ownerProducer = {
requireActivity()
})
private val debouncer = Debouncer(2, TimeUnit.SECONDS)
private lateinit var pagerAdapter: MediaPreviewV2Adapter

View File

@@ -12,7 +12,8 @@ data class MediaPreviewV2State(
val allMediaInAlbumRail: Boolean = false,
val leftIsRecent: Boolean = false,
val albums: Map<Long, List<Media>> = mapOf(),
val messageBodies: Map<Long, SpannableString> = mapOf()
val messageBodies: Map<Long, SpannableString> = mapOf(),
val isInSharedAnimation: Boolean = true
) {
enum class LoadState { INIT, DATA_LOADED, MEDIA_READY }
}

View File

@@ -27,6 +27,10 @@ class MediaPreviewV2ViewModel : ViewModel() {
val currentPosition: Int
get() = store.state.position
fun setIsInSharedAnimation(isInSharedAnimation: Boolean) {
store.update { it.copy(isInSharedAnimation = isInSharedAnimation) }
}
fun fetchAttachments(context: Context, startingAttachmentId: AttachmentId, threadId: Long, sorting: MediaTable.Sorting, forceRefresh: Boolean = false) {
if (store.state.loadState == MediaPreviewV2State.LoadState.INIT || forceRefresh) {
disposables += store.update(repository.getAttachments(context, startingAttachmentId, threadId, sorting)) { result: MediaPreviewRepository.Result, oldState: MediaPreviewV2State ->

View File

@@ -9,6 +9,7 @@ import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.lifecycle.ViewModelProvider;
import com.google.android.exoplayer2.ui.PlayerControlView;
@@ -16,6 +17,7 @@ import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner;
import org.thoughtcrime.securesms.mms.VideoSlide;
import org.thoughtcrime.securesms.util.LifecycleDisposable;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.video.VideoPlayer;
@@ -27,8 +29,11 @@ public final class VideoMediaPreviewFragment extends MediaPreviewFragment {
private static final Long MINIMUM_DURATION_FOR_SKIP_MS = TimeUnit.MILLISECONDS.convert(30, TimeUnit.SECONDS);
private VideoPlayer videoView;
private boolean isVideoGif;
private VideoPlayer videoView;
private boolean isVideoGif;
private MediaPreviewV2ViewModel viewModel;
private LifecycleDisposable lifecycleDisposable;
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
@@ -45,7 +50,14 @@ public final class VideoMediaPreviewFragment extends MediaPreviewFragment {
throw new AssertionError("This fragment can only display video");
}
videoView = itemView.findViewById(R.id.video_player);
videoView = itemView.findViewById(R.id.video_player);
viewModel = new ViewModelProvider(requireActivity()).get(MediaPreviewV2ViewModel.class);
lifecycleDisposable = new LifecycleDisposable();
lifecycleDisposable.add(viewModel.getState().distinctUntilChanged().subscribe(state -> {
Log.d(TAG, "ANIM" + state.isInSharedAnimation());
itemView.setVisibility(state.isInSharedAnimation() ? View.INVISIBLE : View.VISIBLE);
}));
videoView.setWindow(requireActivity().getWindow());
videoView.setVideoSource(new VideoSlide(getContext(), uri, size, false), autoPlay, TAG);