mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-25 03:11:10 +01:00
Add thumbnail shared element animation.
This commit is contained in:
committed by
Greyson Parrelli
parent
2c48d40375
commit
d0de43a6b2
@@ -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
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user