From a894ba7a51b5d07706445617d485023759818db4 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Thu, 7 Apr 2022 16:08:05 -0300 Subject: [PATCH] Implement cross-fade for story thumb shared element animation. --- .../securesms/MediaPreviewActivity.java | 4 + .../transitions/CrossfaderTransition.kt | 63 ++++++ .../components/ZoomingImageView.java | 14 +- .../ConversationParentFragment.java | 2 +- .../ImageMediaPreviewFragment.java | 2 +- .../mediapreview/MediaPreviewFragment.java | 2 +- .../mediapreview/VideoControlsDelegate.kt | 2 - .../VideoMediaPreviewFragment.java | 19 ++ .../bottomsheet/RecipientDialogViewModel.java | 4 +- .../securesms/stories/StoryTextPostModel.kt | 38 +++- .../securesms/stories/StoryTextPostView.kt | 18 +- .../stories/landing/StoriesLandingFragment.kt | 13 +- .../stories/landing/StoriesLandingItem.kt | 4 + .../securesms/stories/my/MyStoriesFragment.kt | 18 +- .../stories/viewer/StoryViewerActivity.kt | 19 +- .../stories/viewer/StoryViewerFragment.kt | 26 ++- .../stories/viewer/StoryViewerState.kt | 21 +- .../stories/viewer/StoryViewerViewModel.kt | 33 +++- .../viewer/page/StoryViewerPageFragment.kt | 84 ++++++-- .../viewer/page/StoryViewerPageViewModel.kt | 4 + .../viewer/page/StoryViewerPlaybackState.kt | 10 +- .../StoriesSharedElementCrossFaderView.kt | 185 ++++++++++++++++++ .../text/StoryTextPostPreviewFragment.kt | 34 +--- .../securesms/util/ActionRequestListener.kt | 32 +++ .../stories_shared_element_crossfader.xml | 29 +++ .../stories_text_post_preview_fragment.xml | 7 - .../res/layout/stories_text_post_view.xml | 2 +- .../layout/stories_viewer_fragment_page.xml | 47 +++-- .../main/res/transition/change_transform.xml | 1 + .../viewer/StoryViewerViewModelTest.kt | 12 +- 30 files changed, 629 insertions(+), 120 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/animation/transitions/CrossfaderTransition.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/StoriesSharedElementCrossFaderView.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/util/ActionRequestListener.kt create mode 100644 app/src/main/res/layout/stories_shared_element_crossfader.xml diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java index bcc0359a3a..18c68a2ae7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java @@ -605,6 +605,10 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity finish(); } + @Override + public void onMediaReady() { + } + private class ViewPagerListener extends ExtendedOnPageChangedListener { @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/animation/transitions/CrossfaderTransition.kt b/app/src/main/java/org/thoughtcrime/securesms/animation/transitions/CrossfaderTransition.kt new file mode 100644 index 0000000000..96f71f8d17 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/animation/transitions/CrossfaderTransition.kt @@ -0,0 +1,63 @@ +package org.thoughtcrime.securesms.animation.transitions + +import android.animation.Animator +import android.animation.ValueAnimator +import android.content.Context +import android.transition.Transition +import android.transition.TransitionValues +import android.util.AttributeSet +import android.view.ViewGroup +import androidx.annotation.RequiresApi +import androidx.core.animation.doOnEnd +import androidx.core.animation.doOnStart + +@RequiresApi(21) +class CrossfaderTransition(context: Context, attrs: AttributeSet?) : Transition(context, attrs) { + + companion object { + private const val WIDTH = "CrossfaderTransition.WIDTH" + } + + override fun captureStartValues(transitionValues: TransitionValues) { + if (transitionValues.view is Crossfadeable) { + transitionValues.values[WIDTH] = transitionValues.view.width + } + } + + override fun captureEndValues(transitionValues: TransitionValues) { + if (transitionValues.view is Crossfadeable) { + transitionValues.values[WIDTH] = transitionValues.view.width + } + } + + override fun createAnimator(sceneRoot: ViewGroup?, startValues: TransitionValues?, endValues: TransitionValues?): Animator? { + if (startValues == null || endValues == null) { + return null + } + + val startWidth = (startValues.values[WIDTH] ?: 0) as Int + val endWidth = (endValues.values[WIDTH] ?: 0) as Int + val view: Crossfadeable = endValues.view as? Crossfadeable ?: return null + val reverse = startWidth > endWidth + + return ValueAnimator.ofFloat(0f, 1f).apply { + addUpdateListener { + view.onCrossfadeAnimationUpdated(it.animatedValue as Float, reverse) + } + + doOnStart { + view.onCrossfadeStarted(reverse) + } + + doOnEnd { + view.onCrossfadeFinished(reverse) + } + } + } + + interface Crossfadeable { + fun onCrossfadeAnimationUpdated(progress: Float, reverse: Boolean) + fun onCrossfadeStarted(reverse: Boolean) + fun onCrossfadeFinished(reverse: Boolean) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ZoomingImageView.java b/app/src/main/java/org/thoughtcrime/securesms/components/ZoomingImageView.java index d4305c48ed..6b6408d40f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ZoomingImageView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ZoomingImageView.java @@ -2,15 +2,20 @@ package org.thoughtcrime.securesms.components; import android.annotation.SuppressLint; import android.content.Context; +import android.graphics.drawable.Drawable; import android.net.Uri; import android.util.AttributeSet; import android.view.View; import android.widget.FrameLayout; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.exifinterface.media.ExifInterface; +import com.bumptech.glide.load.DataSource; import com.bumptech.glide.load.engine.DiskCacheStrategy; +import com.bumptech.glide.load.engine.GlideException; +import com.bumptech.glide.request.RequestListener; import com.bumptech.glide.request.target.Target; import com.davemorrissey.labs.subscaleview.ImageSource; import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView; @@ -24,6 +29,7 @@ import org.thoughtcrime.securesms.components.subsampling.AttachmentRegionDecoder import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri; import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.mms.PartAuthority; +import org.thoughtcrime.securesms.util.ActionRequestListener; import org.thoughtcrime.securesms.util.BitmapDecodingException; import org.thoughtcrime.securesms.util.BitmapUtil; import org.thoughtcrime.securesms.util.MediaUtil; @@ -79,7 +85,7 @@ public class ZoomingImageView extends FrameLayout { } @SuppressLint("StaticFieldLeak") - public void setImageUri(@NonNull GlideRequests glideRequests, @NonNull Uri uri, @NonNull String contentType) + public void setImageUri(@NonNull GlideRequests glideRequests, @NonNull Uri uri, @NonNull String contentType, @NonNull Runnable onMediaReady) { final Context context = getContext(); final int maxTextureSize = BitmapUtil.getMaxTextureSize(); @@ -101,15 +107,16 @@ public class ZoomingImageView extends FrameLayout { if (dimensions == null || (dimensions.first <= maxTextureSize && dimensions.second <= maxTextureSize)) { Log.i(TAG, "Loading in standard image view..."); - setImageViewUri(glideRequests, uri); + setImageViewUri(glideRequests, uri, onMediaReady); } else { Log.i(TAG, "Loading in subsampling image view..."); setSubsamplingImageViewUri(uri); + onMediaReady.run(); } }); } - private void setImageViewUri(@NonNull GlideRequests glideRequests, @NonNull Uri uri) { + private void setImageViewUri(@NonNull GlideRequests glideRequests, @NonNull Uri uri, @NonNull Runnable onMediaReady) { photoView.setVisibility(View.VISIBLE); subsamplingImageView.setVisibility(View.GONE); @@ -117,6 +124,7 @@ public class ZoomingImageView extends FrameLayout { .diskCacheStrategy(DiskCacheStrategy.NONE) .dontTransform() .override(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL) + .addListener(ActionRequestListener.onEither(onMediaReady)) .into(photoView); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java index b93d727475..42b776fb05 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java @@ -1244,7 +1244,7 @@ public class ConversationParentFragment extends Fragment } private void handleStoryRingClick() { - startActivity(StoryViewerActivity.createIntent(requireContext(), recipient.getId(), -1L, recipient.get().shouldHideStory())); + startActivity(StoryViewerActivity.createIntent(requireContext(), recipient.getId(), -1L, recipient.get().shouldHideStory(), null, null)); } private void handleConversationSettings() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/ImageMediaPreviewFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/ImageMediaPreviewFragment.java index bb38d80a5d..73467069ea 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/ImageMediaPreviewFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/ImageMediaPreviewFragment.java @@ -35,7 +35,7 @@ public final class ImageMediaPreviewFragment extends MediaPreviewFragment { } //noinspection ConstantConditions - zoomingImageView.setImageUri(glideRequests, uri, contentType); + zoomingImageView.setImageUri(glideRequests, uri, contentType, () -> events.onMediaReady()); zoomingImageView.setOnClickListener(v -> events.singleTapOnMedia()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewFragment.java index 047455da8b..52dc23ecac 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewFragment.java @@ -77,7 +77,6 @@ public abstract class MediaPreviewFragment extends Fragment { public void onResume() { super.onResume(); checkMediaStillAvailable(); - requireActivity().supportStartPostponedEnterTransition(); } public void cleanUp() { @@ -103,6 +102,7 @@ public abstract class MediaPreviewFragment extends Fragment { public interface Events { boolean singleTapOnMedia(); void mediaNotAvailable(); + void onMediaReady(); default @Nullable VideoControlsDelegate getVideoControlsDelegate() { return null; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/VideoControlsDelegate.kt b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/VideoControlsDelegate.kt index e8e69f4bca..8f51169253 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/VideoControlsDelegate.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/VideoControlsDelegate.kt @@ -28,8 +28,6 @@ class VideoControlsDelegate { } else { playWhenReady[uri] = true } - - this.player?.videoPlayer?.play() } fun restart() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/VideoMediaPreviewFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/VideoMediaPreviewFragment.java index 29dfd5cc29..c074cce8ec 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/VideoMediaPreviewFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/VideoMediaPreviewFragment.java @@ -53,6 +53,25 @@ public final class VideoMediaPreviewFragment extends MediaPreviewFragment { events.getVideoControlsDelegate().onPlayerPositionDiscontinuity(r); } }); + videoView.setPlayerCallback(new VideoPlayer.PlayerCallback() { + @Override + public void onReady() { + events.onMediaReady(); + } + + @Override + public void onPlaying() { + } + + @Override + public void onStopped() { + } + + @Override + public void onError() { + events.mediaNotAvailable(); + } + }); if (isVideoGif) { videoView.hideControls(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogViewModel.java index f17e1e7cfe..079156a455 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogViewModel.java @@ -140,7 +140,7 @@ final class RecipientDialogViewModel extends ViewModel { if (storyViewState.getValue() == null || storyViewState.getValue() == StoryViewState.NONE) { onMessageClicked(activity); } else { - activity.startActivity(StoryViewerActivity.createIntent(activity, recipientDialogRepository.getRecipientId(), -1L, recipient.getValue().shouldHideStory())); + activity.startActivity(StoryViewerActivity.createIntent(activity, recipientDialogRepository.getRecipientId(), -1L, recipient.getValue().shouldHideStory(), null, null)); } } @@ -176,7 +176,7 @@ final class RecipientDialogViewModel extends ViewModel { if (storyViewState.getValue() == null || storyViewState.getValue() == StoryViewState.NONE) { activity.startActivity(ConversationSettingsActivity.forRecipient(activity, recipientDialogRepository.getRecipientId())); } else { - activity.startActivity(StoryViewerActivity.createIntent(activity, recipientDialogRepository.getRecipientId(), -1L, recipient.getValue().shouldHideStory())); + activity.startActivity(StoryViewerActivity.createIntent(activity, recipientDialogRepository.getRecipientId(), -1L, recipient.getValue().shouldHideStory(), null, null)); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/StoryTextPostModel.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/StoryTextPostModel.kt index 36bd392d38..03f05217a1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/StoryTextPostModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/StoryTextPostModel.kt @@ -4,6 +4,8 @@ import android.graphics.Bitmap import android.graphics.Color import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable +import android.os.Parcel +import android.os.Parcelable import android.view.View import androidx.core.graphics.scale import androidx.core.view.drawToBitmap @@ -24,6 +26,7 @@ import org.thoughtcrime.securesms.fonts.TypefaceCache import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.util.Base64 +import org.thoughtcrime.securesms.util.ParcelUtil import java.security.MessageDigest /** @@ -33,7 +36,7 @@ data class StoryTextPostModel( private val storyTextPost: StoryTextPost, private val storySentAtMillis: Long, private val storyAuthor: RecipientId -) : Key { +) : Key, Parcelable { override fun updateDiskCacheKey(messageDigest: MessageDigest) { messageDigest.update(storyTextPost.toByteArray()) @@ -51,7 +54,30 @@ data class StoryTextPostModel( } } - companion object { + override fun writeToParcel(parcel: Parcel, flags: Int) { + ParcelUtil.writeByteArray(parcel, storyTextPost.toByteArray()) + parcel.writeLong(storySentAtMillis) + parcel.writeParcelable(storyAuthor, flags) + } + + override fun describeContents(): Int { + return 0 + } + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): StoryTextPostModel { + val storyTextPostArray = ParcelUtil.readByteArray(parcel) + + return StoryTextPostModel( + StoryTextPost.parseFrom(storyTextPostArray), + parcel.readLong(), + parcel.readParcelable(RecipientId::class.java.classLoader)!! + ) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } fun parseFrom(messageRecord: MessageRecord): StoryTextPostModel { return parseFrom( @@ -74,8 +100,7 @@ data class StoryTextPostModel( class Decoder : ResourceDecoder { companion object { - private const val RENDER_WIDTH = 1080 - private const val RENDER_HEIGHT = 1920 + private const val RENDER_HW_AR = 16f / 9f } override fun handles(source: StoryTextPostModel, options: Options): Boolean = true @@ -89,12 +114,15 @@ data class StoryTextPostModel( TextToScript.guessScript(source.storyTextPost.body) ).blockingGet() + val displayWidth: Int = ApplicationDependencies.getApplication().resources.displayMetrics.widthPixels + val arHeight: Int = (RENDER_HW_AR * displayWidth).toInt() + view.setTypeface(typeface) view.bindFromStoryTextPost(source.storyTextPost) view.bindLinkPreview((message as? MmsMessageRecord)?.linkPreviews?.firstOrNull()) view.invalidate() - view.measure(View.MeasureSpec.makeMeasureSpec(RENDER_WIDTH, View.MeasureSpec.EXACTLY), View.MeasureSpec.makeMeasureSpec(RENDER_HEIGHT, View.MeasureSpec.EXACTLY)) + view.measure(View.MeasureSpec.makeMeasureSpec(displayWidth, View.MeasureSpec.EXACTLY), View.MeasureSpec.makeMeasureSpec(arHeight, View.MeasureSpec.EXACTLY)) view.layout(0, 0, view.measuredWidth, view.measuredHeight) val bitmap = view.drawToBitmap().scale(width, height) diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/StoryTextPostView.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/StoryTextPostView.kt index e7f5c841ab..d4d1525a39 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/StoryTextPostView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/StoryTextPostView.kt @@ -6,12 +6,12 @@ import android.graphics.drawable.Drawable import android.util.AttributeSet import android.util.TypedValue import android.view.View +import android.widget.ImageView import androidx.annotation.ColorInt import androidx.annotation.Px import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.view.doOnNextLayout import androidx.core.view.isVisible -import com.google.android.material.imageview.ShapeableImageView import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.conversation.colors.ChatColors import org.thoughtcrime.securesms.database.model.databaseprotos.StoryTextPost @@ -22,7 +22,6 @@ import org.thoughtcrime.securesms.mediasend.v2.text.TextAlignment import org.thoughtcrime.securesms.mediasend.v2.text.TextStoryPostCreationState import org.thoughtcrime.securesms.mediasend.v2.text.TextStoryScale import org.thoughtcrime.securesms.mediasend.v2.text.TextStoryTextWatcher -import org.thoughtcrime.securesms.stories.viewer.page.StoryDisplay import org.thoughtcrime.securesms.util.concurrent.ListenableFuture import org.thoughtcrime.securesms.util.visible import java.util.Locale @@ -38,7 +37,7 @@ class StoryTextPostView @JvmOverloads constructor( } private var textAlignment: TextAlignment? = null - private val backgroundView: ShapeableImageView = findViewById(R.id.text_story_post_background) + private val backgroundView: ImageView = findViewById(R.id.text_story_post_background) private val textView: StoryTextView = findViewById(R.id.text_story_post_text) private val linkPreviewView: StoryLinkPreviewView = findViewById(R.id.text_story_post_link_preview) @@ -46,19 +45,6 @@ class StoryTextPostView @JvmOverloads constructor( init { TextStoryTextWatcher.install(textView) - - val displaySize = StoryDisplay.getStoryDisplay( - resources.displayMetrics.widthPixels.toFloat(), - resources.displayMetrics.heightPixels.toFloat() - ) - - when (displaySize) { - StoryDisplay.SMALL -> - backgroundView.shapeAppearanceModel = backgroundView.shapeAppearanceModel - .toBuilder() - .setAllCornerSizes(0f) - .build() - } } fun showCloseButton() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingFragment.kt index 541997bf1c..d5eabbd435 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingFragment.kt @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.stories.landing import android.Manifest import android.content.Intent import android.graphics.Color +import android.net.Uri import android.os.Bundle import android.transition.TransitionInflater import android.view.Menu @@ -27,8 +28,10 @@ import org.thoughtcrime.securesms.conversation.ConversationIntents import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord +import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity import org.thoughtcrime.securesms.permissions.Permissions +import org.thoughtcrime.securesms.stories.StoryTextPostModel import org.thoughtcrime.securesms.stories.dialogs.StoryContextMenu import org.thoughtcrime.securesms.stories.my.MyStoriesActivity import org.thoughtcrime.securesms.stories.settings.StorySettingsActivity @@ -165,7 +168,15 @@ class StoriesLandingFragment : DSLSettingsFragment(layoutId = R.layout.stories_l Toast.makeText(requireContext(), R.string.message_recipients_list_item__resend, Toast.LENGTH_SHORT).show() } else { val options = ActivityOptionsCompat.makeSceneTransitionAnimation(requireActivity(), preview, ViewCompat.getTransitionName(preview) ?: "") - startActivity(StoryViewerActivity.createIntent(requireContext(), model.data.storyRecipient.id, -1L, model.data.isHidden), options.toBundle()) + + val record = model.data.primaryStory.messageRecord as MmsMessageRecord + val (text: StoryTextPostModel?, image: Uri?) = if (record.storyType.isTextStory) { + StoryTextPostModel.parseFrom(record) to null + } else { + null to record.slideDeck.thumbnailSlide?.uri + } + + startActivity(StoryViewerActivity.createIntent(requireContext(), model.data.storyRecipient.id, -1L, model.data.isHidden, text, image), options.toBundle()) } }, onForwardStory = { diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingItem.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingItem.kt index a5c2be4d1d..a4841b47a0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingItem.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingItem.kt @@ -140,6 +140,7 @@ object StoriesLandingItem { .addListener(HideBlurAfterLoadListener()) .placeholder(storyTextPostModel.getPlaceholder()) .centerCrop() + .dontAnimate() .into(storyPreview) } else if (thumbnail != null) { storyBlur.visible = blur != null @@ -147,6 +148,7 @@ object StoriesLandingItem { .load(DecryptableStreamUriLoader.DecryptableUri(thumbnail)) .addListener(HideBlurAfterLoadListener()) .centerCrop() + .dontAnimate() .into(storyPreview) } @@ -162,12 +164,14 @@ object StoriesLandingItem { .load(storyTextPostModel) .placeholder(storyTextPostModel.getPlaceholder()) .centerCrop() + .dontAnimate() .into(storyMulti) storyMulti.visible = true } else if (secondaryThumb != null) { GlideApp.with(storyMulti) .load(DecryptableStreamUriLoader.DecryptableUri(secondaryThumb)) .centerCrop() + .dontAnimate() .into(storyMulti) storyMulti.visible = true } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesFragment.kt index d81455876d..bb68ba325b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesFragment.kt @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.stories.my +import android.net.Uri import android.view.View import android.widget.Toast import androidx.activity.OnBackPressedCallback @@ -16,7 +17,9 @@ import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectFor import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord +import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.stories.StoryTextPostModel import org.thoughtcrime.securesms.stories.dialogs.StoryContextMenu import org.thoughtcrime.securesms.stories.viewer.StoryViewerActivity import org.thoughtcrime.securesms.util.LifecycleDisposable @@ -81,14 +84,21 @@ class MyStoriesFragment : DSLSettingsFragment( Toast.makeText(requireContext(), R.string.message_recipients_list_item__resend, Toast.LENGTH_SHORT).show() } } else { - val recipientId = if (it.distributionStory.messageRecord.recipient.isGroup) { - it.distributionStory.messageRecord.recipient.id + val recipient = if (it.distributionStory.messageRecord.recipient.isGroup) { + it.distributionStory.messageRecord.recipient } else { - Recipient.self().id + Recipient.self() + } + + val record = it.distributionStory.messageRecord as MmsMessageRecord + val (text: StoryTextPostModel?, image: Uri?) = if (record.storyType.isTextStory) { + StoryTextPostModel.parseFrom(record) to null + } else { + null to record.slideDeck.thumbnailSlide?.uri } val options = ActivityOptionsCompat.makeSceneTransitionAnimation(requireActivity(), preview, ViewCompat.getTransitionName(preview) ?: "") - startActivity(StoryViewerActivity.createIntent(requireContext(), recipientId, conversationMessage.messageRecord.id), options.toBundle()) + startActivity(StoryViewerActivity.createIntent(requireContext(), recipient.id, conversationMessage.messageRecord.id, recipient.shouldHideStory(), text, image), options.toBundle()) } }, onLongClick = { diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerActivity.kt index eccb379020..943649cc77 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerActivity.kt @@ -2,11 +2,13 @@ package org.thoughtcrime.securesms.stories.viewer import android.content.Context import android.content.Intent +import android.net.Uri import android.os.Bundle import androidx.appcompat.app.AppCompatDelegate import org.thoughtcrime.securesms.PassphraseRequiredActivity import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.stories.StoryTextPostModel class StoryViewerActivity : PassphraseRequiredActivity() { @@ -28,7 +30,9 @@ class StoryViewerActivity : PassphraseRequiredActivity() { StoryViewerFragment.create( intent.getParcelableExtra(ARG_START_RECIPIENT_ID)!!, intent.getLongExtra(ARG_START_STORY_ID, -1L), - intent.getBooleanExtra(ARG_HIDDEN_STORIES, false) + intent.getBooleanExtra(ARG_HIDDEN_STORIES, false), + intent.getParcelableExtra(ARG_CROSSFADE_TEXT_MODEL), + intent.getParcelableExtra(ARG_CROSSFADE_IMAGE_URI) ) ) .commit() @@ -39,13 +43,24 @@ class StoryViewerActivity : PassphraseRequiredActivity() { private const val ARG_START_RECIPIENT_ID = "start.recipient.id" private const val ARG_START_STORY_ID = "start.story.id" private const val ARG_HIDDEN_STORIES = "hidden_stories" + private const val ARG_CROSSFADE_TEXT_MODEL = "crossfade.text.model" + private const val ARG_CROSSFADE_IMAGE_URI = "crossfade.image.uri" @JvmStatic - fun createIntent(context: Context, recipientId: RecipientId, storyId: Long = -1L, onlyIncludeHiddenStories: Boolean = false): Intent { + fun createIntent( + context: Context, + recipientId: RecipientId, + storyId: Long = -1L, + onlyIncludeHiddenStories: Boolean = false, + storyThumbTextModel: StoryTextPostModel? = null, + storyThumbUri: Uri? = null + ): Intent { return Intent(context, StoryViewerActivity::class.java) .putExtra(ARG_START_RECIPIENT_ID, recipientId) .putExtra(ARG_START_STORY_ID, storyId) .putExtra(ARG_HIDDEN_STORIES, onlyIncludeHiddenStories) + .putExtra(ARG_CROSSFADE_TEXT_MODEL, storyThumbTextModel) + .putExtra(ARG_CROSSFADE_IMAGE_URI, storyThumbUri) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerFragment.kt index 70b610c742..d5fbecd9a2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerFragment.kt @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.stories.viewer +import android.net.Uri import android.os.Bundle import android.view.View import androidx.fragment.app.Fragment @@ -8,6 +9,7 @@ import androidx.lifecycle.LiveDataReactiveStreams import androidx.viewpager2.widget.ViewPager2 import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.stories.StoryTextPostModel import org.thoughtcrime.securesms.stories.viewer.page.StoryViewerPageFragment /** @@ -21,7 +23,7 @@ class StoryViewerFragment : Fragment(R.layout.stories_viewer_fragment), StoryVie private val viewModel: StoryViewerViewModel by viewModels( factoryProducer = { - StoryViewerViewModel.Factory(storyRecipientId, onlyIncludeHiddenStories, StoryViewerRepository()) + StoryViewerViewModel.Factory(storyRecipientId, onlyIncludeHiddenStories, storyThumbTextModel, storyThumbUri, StoryViewerRepository()) } ) @@ -34,6 +36,12 @@ class StoryViewerFragment : Fragment(R.layout.stories_viewer_fragment), StoryVie private val onlyIncludeHiddenStories: Boolean get() = requireArguments().getBoolean(ARG_HIDDEN_STORIES) + private val storyThumbTextModel: StoryTextPostModel? + get() = requireArguments().getParcelable(ARG_CROSSFADE_TEXT_MODEL) + + private val storyThumbUri: Uri? + get() = requireArguments().getParcelable(ARG_CROSSFADE_IMAGE_URI) + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { storyPager = view.findViewById(R.id.story_item_pager) @@ -53,6 +61,10 @@ class StoryViewerFragment : Fragment(R.layout.stories_viewer_fragment), StoryVie requireActivity().onBackPressed() } } + + if (state.loadState.isReady()) { + requireActivity().supportStartPostponedEnterTransition() + } } } @@ -94,13 +106,23 @@ class StoryViewerFragment : Fragment(R.layout.stories_viewer_fragment), StoryVie private const val ARG_START_RECIPIENT_ID = "start.recipient.id" private const val ARG_START_STORY_ID = "start.story.id" private const val ARG_HIDDEN_STORIES = "hidden_stories" + private const val ARG_CROSSFADE_TEXT_MODEL = "crossfade.text.model" + private const val ARG_CROSSFADE_IMAGE_URI = "crossfade.image.uri" - fun create(storyRecipientId: RecipientId, storyId: Long, onlyIncludeHiddenStories: Boolean): Fragment { + fun create( + storyRecipientId: RecipientId, + storyId: Long, + onlyIncludeHiddenStories: Boolean, + storyThumbTextModel: StoryTextPostModel? = null, + storyThumbUri: Uri? = null + ): Fragment { return StoryViewerFragment().apply { arguments = Bundle().apply { putParcelable(ARG_START_RECIPIENT_ID, storyRecipientId) putLong(ARG_START_STORY_ID, storyId) putBoolean(ARG_HIDDEN_STORIES, onlyIncludeHiddenStories) + putParcelable(ARG_CROSSFADE_TEXT_MODEL, storyThumbTextModel) + putParcelable(ARG_CROSSFADE_IMAGE_URI, storyThumbUri) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerState.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerState.kt index 2baaa2b03c..e19a8c1dd8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerState.kt @@ -1,9 +1,26 @@ package org.thoughtcrime.securesms.stories.viewer +import android.net.Uri import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.stories.StoryTextPostModel data class StoryViewerState( val pages: List = emptyList(), val previousPage: Int = -1, - val page: Int = -1 -) + val page: Int = -1, + val crossfadeSource: CrossfadeSource, + val loadState: LoadState = LoadState() +) { + sealed class CrossfadeSource { + object None : CrossfadeSource() + class ImageUri(val imageUri: Uri) : CrossfadeSource() + class TextModel(val storyTextPostModel: StoryTextPostModel) : CrossfadeSource() + } + + data class LoadState( + val isContentReady: Boolean = false, + val isCrossfaderReady: Boolean = false + ) { + fun isReady(): Boolean = isContentReady && isCrossfaderReady + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerViewModel.kt index 885d1f96f8..a3e6044ab5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerViewModel.kt @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.stories.viewer +import android.net.Uri import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel @@ -8,16 +9,28 @@ import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.kotlin.plusAssign import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.stories.StoryTextPostModel import org.thoughtcrime.securesms.util.rx.RxStore import kotlin.math.max class StoryViewerViewModel( private val startRecipientId: RecipientId, private val onlyIncludeHiddenStories: Boolean, - private val repository: StoryViewerRepository + storyThumbTextModel: StoryTextPostModel?, + storyThumbUri: Uri?, + private val repository: StoryViewerRepository, ) : ViewModel() { - private val store = RxStore(StoryViewerState()) + private val store = RxStore( + StoryViewerState( + crossfadeSource = when { + storyThumbTextModel != null -> StoryViewerState.CrossfadeSource.TextModel(storyThumbTextModel) + storyThumbUri != null -> StoryViewerState.CrossfadeSource.ImageUri(storyThumbUri) + else -> StoryViewerState.CrossfadeSource.None + } + ) + ) + private val disposables = CompositeDisposable() val stateSnapshot: StoryViewerState get() = store.state @@ -33,6 +46,18 @@ class StoryViewerViewModel( refresh() } + fun setContentIsReady() { + store.update { + it.copy(loadState = it.loadState.copy(isContentReady = true)) + } + } + + fun setCrossfaderIsReady() { + store.update { + it.copy(loadState = it.loadState.copy(isCrossfaderReady = true)) + } + } + fun setIsScrolling(isScrolling: Boolean) { scrollStatePublisher.value = isScrolling } @@ -127,10 +152,12 @@ class StoryViewerViewModel( class Factory( private val startRecipientId: RecipientId, private val onlyIncludeHiddenStories: Boolean, + private val storyThumbTextModel: StoryTextPostModel?, + private val storyThumbUri: Uri?, private val repository: StoryViewerRepository ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { - return modelClass.cast(StoryViewerViewModel(startRecipientId, onlyIncludeHiddenStories, repository)) as T + return modelClass.cast(StoryViewerViewModel(startRecipientId, onlyIncludeHiddenStories, storyThumbTextModel, storyThumbUri, repository)) as T } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt index 6a95de9fb8..3a12209481 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt @@ -11,6 +11,7 @@ import android.view.GestureDetector import android.view.MotionEvent import android.view.View import android.view.animation.Interpolator +import android.widget.FrameLayout import android.widget.ImageView import android.widget.TextView import androidx.cardview.widget.CardView @@ -40,6 +41,7 @@ import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectFor import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs import org.thoughtcrime.securesms.database.AttachmentDatabase import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord +import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.mediapreview.MediaPreviewFragment import org.thoughtcrime.securesms.mediapreview.VideoControlsDelegate import org.thoughtcrime.securesms.mms.GlideApp @@ -47,7 +49,9 @@ import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.stories.StorySlateView import org.thoughtcrime.securesms.stories.dialogs.StoryContextMenu +import org.thoughtcrime.securesms.stories.viewer.StoryViewerState import org.thoughtcrime.securesms.stories.viewer.StoryViewerViewModel +import org.thoughtcrime.securesms.stories.viewer.reply.StoriesSharedElementCrossFaderView import org.thoughtcrime.securesms.stories.viewer.reply.direct.StoryDirectReplyDialogFragment import org.thoughtcrime.securesms.stories.viewer.reply.group.StoryGroupReplyBottomSheetDialogFragment import org.thoughtcrime.securesms.stories.viewer.reply.reaction.OnReactionSentView @@ -73,11 +77,16 @@ class StoryViewerPageFragment : MediaPreviewFragment.Events, MultiselectForwardBottomSheet.Callback, StorySlateView.Callback, - StoryTextPostPreviewFragment.Callback { + StoryTextPostPreviewFragment.Callback, + StoriesSharedElementCrossFaderView.Callback { private lateinit var progressBar: SegmentedProgressBar private lateinit var storySlate: StorySlateView private lateinit var viewsAndReplies: TextView + private lateinit var storyCrossfader: StoriesSharedElementCrossFaderView + private lateinit var blurContainer: ImageView + private lateinit var storyCaptionContainer: FrameLayout + private lateinit var storyContentContainer: FrameLayout private lateinit var callback: Callback @@ -122,13 +131,19 @@ class StoryViewerPageFragment : val largeCaption: TextView = view.findViewById(R.id.story_large_caption) val largeCaptionOverlay: View = view.findViewById(R.id.story_large_caption_overlay) val reactionAnimationView: OnReactionSentView = view.findViewById(R.id.on_reaction_sent_view) - val blurContainer: ImageView = view.findViewById(R.id.story_blur_container) + val storyGradientTop: View = view.findViewById(R.id.story_gradient_top) + val storyGradientBottom: View = view.findViewById(R.id.story_gradient_bottom) + blurContainer = view.findViewById(R.id.story_blur_container) + storyContentContainer = view.findViewById(R.id.story_content_container) + storyCaptionContainer = view.findViewById(R.id.story_caption_container) storySlate = view.findViewById(R.id.story_slate) progressBar = view.findViewById(R.id.progress) viewsAndReplies = view.findViewById(R.id.views_and_replies_bar) + storyCrossfader = view.findViewById(R.id.story_content_crossfader) storySlate.callback = this + storyCrossfader.callback = this chrome = listOf( closeView, @@ -139,7 +154,9 @@ class StoryViewerPageFragment : moreButton, distributionList, viewsAndReplies, - progressBar + progressBar, + storyGradientTop, + storyGradientBottom ) senderAvatar.setFallbackPhotoProvider(FallbackPhotoProvider()) @@ -157,7 +174,6 @@ class StoryViewerPageFragment : viewModel::goToPreviousPost, this::startReply, sharedViewModel = sharedViewModel - ) ) @@ -166,10 +182,8 @@ class StoryViewerPageFragment : val result = gestureDetector.onTouchEvent(event) if (event.actionMasked == MotionEvent.ACTION_DOWN) { viewModel.setIsUserTouching(true) - hideChrome() } else if (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL) { viewModel.setIsUserTouching(false) - showChrome() val canCloseFromHorizontalSlide = requireView().translationX > DimensionUnit.DP.toPixels(56f) val canCloseFromVerticalSlide = requireView().translationY > DimensionUnit.DP.toPixels(56f) @@ -198,10 +212,6 @@ class StoryViewerPageFragment : if (oldPageIndex != newPageIndex && context != null) { viewModel.setSelectedPostIndex(newPageIndex) } - - if (oldPageIndex == newPageIndex) { - videoControlsDelegate.restart() - } } override fun onFinished() { @@ -238,7 +248,7 @@ class StoryViewerPageFragment : viewModel.setIsUserScrollingParent(isScrolling) } - LiveDataReactiveStreams.fromPublisher(sharedViewModel.state).observe(viewLifecycleOwner) { parentState -> + LiveDataReactiveStreams.fromPublisher(sharedViewModel.state.distinct()).observe(viewLifecycleOwner) { parentState -> if (parentState.pages.size <= parentState.page) { viewModel.setIsSelectedPage(false) } else if (storyRecipientId == parentState.pages[parentState.page]) { @@ -248,6 +258,11 @@ class StoryViewerPageFragment : videoControlsDelegate.restart() } viewModel.setIsSelectedPage(true) + when (parentState.crossfadeSource) { + is StoryViewerState.CrossfadeSource.TextModel -> storyCrossfader.setSourceView(parentState.crossfadeSource.storyTextPostModel) + is StoryViewerState.CrossfadeSource.ImageUri -> storyCrossfader.setSourceView(parentState.crossfadeSource.imageUri) + else -> onReadyToAnimate() + } } else { viewModel.setIsSelectedPage(false) } @@ -286,6 +301,10 @@ class StoryViewerPageFragment : presentStory(post, state.selectedPostIndex) presentSlate(post) + if (!storyCrossfader.setTargetView(post.conversationMessage.messageRecord as MmsMessageRecord)) { + onReadyToAnimate() + } + viewModel.setAreSegmentsInitialized(true) } else if (state.selectedPostIndex >= state.posts.size) { callback.onFinishedPosts(storyRecipientId) @@ -300,6 +319,21 @@ class StoryViewerPageFragment : } else { resumeProgress() } + + when { + state.hideChromeImmediate -> { + hideChromeImmediate() + storyCaptionContainer.visible = false + } + state.hideChrome -> { + hideChrome() + storyCaptionContainer.visible = true + } + else -> { + showChrome() + storyCaptionContainer.visible = true + } + } } timeoutDisposable.bindTo(viewLifecycleOwner) @@ -372,6 +406,13 @@ class StoryViewerPageFragment : } } + private fun hideChromeImmediate() { + animatorSet?.cancel() + chrome.map { + it.alpha = 0f + } + } + private fun hideChrome() { animateChrome(0f) } @@ -892,7 +933,28 @@ class StoryViewerPageFragment : return false } + override fun onMediaReady() { + sharedViewModel.setContentIsReady() + } + override fun mediaNotAvailable() { + sharedViewModel.setContentIsReady() + } + + override fun onReadyToAnimate() { + sharedViewModel.setCrossfaderIsReady() + } + + override fun onAnimationStarted() { + storyContentContainer.alpha = 0f + blurContainer.alpha = 0f + viewModel.setIsRunningSharedElementAnimation(true) + } + + override fun onAnimationFinished() { + storyContentContainer.alpha = 1f + blurContainer.alpha = 1f + viewModel.setIsRunningSharedElementAnimation(false) } interface Callback { diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageViewModel.kt index 6fc2f5f1d0..f61bf98f97 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageViewModel.kt @@ -194,6 +194,10 @@ class StoryViewerPageViewModel( storyViewerPlaybackStore.update { it.copy(isDisplayingLinkPreviewTooltip = isDisplayingLinkPreviewTooltip) } } + fun setIsRunningSharedElementAnimation(isRunningSharedElementAnimation: Boolean) { + storyViewerPlaybackStore.update { it.copy(isRunningSharedElementAnimation = isRunningSharedElementAnimation) } + } + private fun resolveSwipeToReplyState(state: StoryViewerPageState, index: Int): StoryViewerPageState.ReplyState { if (index !in state.posts.indices) { return StoryViewerPageState.ReplyState.NONE diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPlaybackState.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPlaybackState.kt index 82498b0162..ad9483562b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPlaybackState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPlaybackState.kt @@ -14,8 +14,13 @@ data class StoryViewerPlaybackState( val isDisplayingSlate: Boolean = false, val isFragmentResumed: Boolean = false, val isDisplayingLinkPreviewTooltip: Boolean = false, - val isDisplayingReactionAnimation: Boolean = false + val isDisplayingReactionAnimation: Boolean = false, + val isRunningSharedElementAnimation: Boolean = false ) { + val hideChromeImmediate: Boolean = isRunningSharedElementAnimation + + val hideChrome: Boolean = isRunningSharedElementAnimation || isUserTouching + val isPaused: Boolean = !areSegmentsInitialized || isUserTouching || isDisplayingCaptionOverlay || @@ -30,5 +35,6 @@ data class StoryViewerPlaybackState( isDisplayingSlate || !isFragmentResumed || isDisplayingLinkPreviewTooltip || - isDisplayingReactionAnimation + isDisplayingReactionAnimation || + isRunningSharedElementAnimation } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/StoriesSharedElementCrossFaderView.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/StoriesSharedElementCrossFaderView.kt new file mode 100644 index 0000000000..eaa1a3f123 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/StoriesSharedElementCrossFaderView.kt @@ -0,0 +1,185 @@ +package org.thoughtcrime.securesms.stories.viewer.reply + +import android.content.Context +import android.graphics.drawable.Drawable +import android.net.Uri +import android.util.AttributeSet +import android.widget.ImageView +import androidx.constraintlayout.widget.ConstraintLayout +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 org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.animation.transitions.CrossfaderTransition +import org.thoughtcrime.securesms.database.model.MmsMessageRecord +import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader +import org.thoughtcrime.securesms.mms.GlideApp +import org.thoughtcrime.securesms.stories.StoryTextPostModel + +class StoriesSharedElementCrossFaderView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : ConstraintLayout(context, attrs), CrossfaderTransition.Crossfadeable { + + init { + inflate(context, R.layout.stories_shared_element_crossfader, this) + } + + private val sourceView: ImageView = findViewById(R.id.source_image) + private val targetView: ImageView = findViewById(R.id.target_image) + + private var isSourceReady: Boolean = false + private var isTargetReady: Boolean = false + + private var latestSource: Any? = null + private var latestTarget: Any? = null + + var callback: Callback? = null + + fun setSourceView(storyTextPostModel: StoryTextPostModel) { + if (latestSource == storyTextPostModel) { + return + } + + latestSource = storyTextPostModel + + GlideApp.with(sourceView) + .load(storyTextPostModel) + .addListener( + OnReadyListener { + isSourceReady = true + notifyIfReady() + } + ) + .placeholder(storyTextPostModel.getPlaceholder()) + .dontAnimate() + .centerCrop() + .into(sourceView) + } + + fun setSourceView(uri: Uri) { + if (latestSource == uri) { + return + } + + latestSource = uri + + GlideApp.with(sourceView) + .load(DecryptableStreamUriLoader.DecryptableUri(uri)) + .addListener( + OnReadyListener { + isSourceReady = true + notifyIfReady() + } + ) + .dontAnimate() + .centerCrop() + .into(sourceView) + } + + fun setTargetView(messageRecord: MmsMessageRecord): Boolean { + val thumbUri = messageRecord.slideDeck.thumbnailSlide?.uri + when { + messageRecord.storyType.isTextStory -> setTargetView(StoryTextPostModel.parseFrom(messageRecord)) + thumbUri != null -> setTargetView(thumbUri) + else -> return false + } + + return true + } + + private fun setTargetView(storyTextPostModel: StoryTextPostModel) { + if (latestTarget == storyTextPostModel) { + return + } + + latestTarget = storyTextPostModel + + GlideApp.with(targetView) + .load(storyTextPostModel) + .addListener( + OnReadyListener { + isTargetReady = true + notifyIfReady() + } + ) + .dontAnimate() + .placeholder(storyTextPostModel.getPlaceholder()) + .centerCrop() + .into(targetView) + } + + private fun setTargetView(uri: Uri) { + if (latestTarget == uri) { + return + } + + latestTarget = uri + + GlideApp.with(targetView) + .load(DecryptableStreamUriLoader.DecryptableUri(uri)) + .addListener( + OnReadyListener { + isTargetReady = true + notifyIfReady() + } + ) + .dontAnimate() + .centerCrop() + .into(targetView) + } + + private fun notifyIfReady() { + if (isSourceReady && isTargetReady) { + callback?.onReadyToAnimate() + } + } + + private class OnReadyListener(private val onReady: () -> Unit) : RequestListener { + override fun onLoadFailed(e: GlideException?, model: Any?, target: Target?, isFirstResource: Boolean): Boolean { + onReady() + return false + } + + override fun onResourceReady(resource: Drawable?, model: Any?, target: Target?, dataSource: DataSource?, isFirstResource: Boolean): Boolean { + onReady() + return false + } + } + + override fun onCrossfadeAnimationUpdated(progress: Float, reverse: Boolean) { + if (reverse) { + sourceView.alpha = progress + targetView.alpha = 1f - progress + } else { + sourceView.alpha = 1f - progress + targetView.alpha = progress + } + } + + override fun onCrossfadeStarted(reverse: Boolean) { + alpha = 1f + + sourceView.alpha = if (reverse) 0f else 1f + targetView.alpha = if (reverse) 1f else 0f + + callback?.onAnimationStarted() + } + + override fun onCrossfadeFinished(reverse: Boolean) { + if (reverse) { + return + } + + animate().alpha(0f) + + callback?.onAnimationFinished() + } + + interface Callback { + fun onReadyToAnimate() + fun onAnimationStarted() + fun onAnimationFinished() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/text/StoryTextPostPreviewFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/text/StoryTextPostPreviewFragment.kt index eab9326c15..5608aabba6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/text/StoryTextPostPreviewFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/text/StoryTextPostPreviewFragment.kt @@ -4,11 +4,7 @@ import android.annotation.SuppressLint import android.os.Bundle import android.view.LayoutInflater import android.view.View -import android.widget.ImageView import android.widget.TextView -import androidx.activity.addCallback -import androidx.core.view.doOnNextLayout -import androidx.core.view.drawToBitmap import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import org.signal.core.util.DimensionUnit @@ -20,7 +16,6 @@ import org.thoughtcrime.securesms.stories.viewer.page.StoryPost import org.thoughtcrime.securesms.util.CommunicationActions import org.thoughtcrime.securesms.util.FragmentDialogs.displayInDialogAboveAnchor import org.thoughtcrime.securesms.util.fragments.requireListener -import org.thoughtcrime.securesms.util.visible class StoryTextPostPreviewFragment : Fragment(R.layout.stories_text_post_preview_fragment) { @@ -45,12 +40,6 @@ class StoryTextPostPreviewFragment : Fragment(R.layout.stories_text_post_preview override fun onViewCreated(view: View, savedInstanceState: Bundle?) { val storyTextPostView: StoryTextPostView = view.findViewById(R.id.story_text_post) - val storyTextThumb: ImageView = view.findViewById(R.id.story_text_post_shared_element_target) - - requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner) { - storyTextThumb.visible = true - requireActivity().supportFinishAfterTransition() - } viewModel.state.observe(viewLifecycleOwner) { state -> when (state.loadState) { @@ -68,7 +57,6 @@ class StoryTextPostPreviewFragment : Fragment(R.layout.stories_text_post_preview } } StoryTextPostState.LoadState.FAILED -> { - requireActivity().supportStartPostponedEnterTransition() requireListener().mediaNotAvailable() } } @@ -78,31 +66,11 @@ class StoryTextPostPreviewFragment : Fragment(R.layout.stories_text_post_preview } if (state.typeface != null && state.loadState == StoryTextPostState.LoadState.LOADED) { - loadPreview(storyTextThumb, storyTextPostView) + requireListener().onMediaReady() } } } - private fun loadPreview(storyTextThumb: ImageView, storyTextPreview: StoryTextPostView) { - storyTextPreview.doOnNextLayout { - if (it.isLaidOut) { - actualLoadPreview(storyTextThumb, storyTextPreview) - } else { - it.post { - actualLoadPreview(storyTextThumb, storyTextPreview) - } - } - } - } - - private fun actualLoadPreview(storyTextThumb: ImageView, storyTextPreview: StoryTextPostView) { - storyTextThumb.setImageBitmap(storyTextPreview.drawToBitmap()) - requireActivity().supportStartPostponedEnterTransition() - storyTextThumb.postDelayed({ - storyTextThumb.visible = false - }, 200) - } - @SuppressLint("AlertDialogBuilderUsage") private fun showLinkPreviewTooltip(view: View, linkPreview: LinkPreview) { requireListener().setIsDisplayingLinkPreviewTooltip(true) diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ActionRequestListener.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ActionRequestListener.kt new file mode 100644 index 0000000000..c09e7c5492 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ActionRequestListener.kt @@ -0,0 +1,32 @@ +package org.thoughtcrime.securesms.util + +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 + +/** + * Performs actions in onResourceReady and onLoadFailed + */ +class ActionRequestListener( + private val onResourceReady: Runnable, + private val onLoadFailed: Runnable +) : RequestListener { + + companion object { + @JvmStatic + fun onEither(onEither: Runnable): ActionRequestListener { + return ActionRequestListener(onEither, onEither) + } + } + + override fun onLoadFailed(e: GlideException?, model: Any?, target: Target?, isFirstResource: Boolean): Boolean { + onLoadFailed.run() + return false + } + + override fun onResourceReady(resource: T, model: Any?, target: Target?, dataSource: DataSource?, isFirstResource: Boolean): Boolean { + onResourceReady.run() + return false + } +} diff --git a/app/src/main/res/layout/stories_shared_element_crossfader.xml b/app/src/main/res/layout/stories_shared_element_crossfader.xml new file mode 100644 index 0000000000..c864dc76b0 --- /dev/null +++ b/app/src/main/res/layout/stories_shared_element_crossfader.xml @@ -0,0 +1,29 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/stories_text_post_preview_fragment.xml b/app/src/main/res/layout/stories_text_post_preview_fragment.xml index 465e6f3e89..8cfe93c4ec 100644 --- a/app/src/main/res/layout/stories_text_post_preview_fragment.xml +++ b/app/src/main/res/layout/stories_text_post_preview_fragment.xml @@ -7,11 +7,4 @@ android:id="@+id/story_text_post" android:layout_width="match_parent" android:layout_height="match_parent" /> - - diff --git a/app/src/main/res/layout/stories_text_post_view.xml b/app/src/main/res/layout/stories_text_post_view.xml index 5f8b6053ae..6edf9fa822 100644 --- a/app/src/main/res/layout/stories_text_post_view.xml +++ b/app/src/main/res/layout/stories_text_post_view.xml @@ -6,7 +6,7 @@ android:layout_height="match_parent" tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout"> - @@ -38,12 +38,23 @@ android:layout_height="match_parent" tools:background="@drawable/test_gradient" /> + + - + app:layout_constraintVertical_bias="1"> + + + + diff --git a/app/src/test/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerViewModelTest.kt index 5ca58f142f..4f9664490b 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerViewModelTest.kt @@ -36,7 +36,7 @@ class StoryViewerViewModelTest { whenever(repository.getStories(any())).doReturn(Single.just(stories)) // WHEN - val testSubject = StoryViewerViewModel(startStory, false, repository) + val testSubject = StoryViewerViewModel(startStory, false, null, null, repository) testScheduler.triggerActions() // THEN @@ -52,7 +52,7 @@ class StoryViewerViewModelTest { val stories: List = (1L..5L).map(RecipientId::from) val startStory = RecipientId.from(1L) whenever(repository.getStories(any())).doReturn(Single.just(stories)) - val testSubject = StoryViewerViewModel(startStory, false, repository) + val testSubject = StoryViewerViewModel(startStory, false, null, null, repository) testScheduler.triggerActions() // WHEN @@ -72,7 +72,7 @@ class StoryViewerViewModelTest { val stories: List = (1L..5L).map(RecipientId::from) val startStory = stories.last() whenever(repository.getStories(any())).doReturn(Single.just(stories)) - val testSubject = StoryViewerViewModel(startStory, false, repository) + val testSubject = StoryViewerViewModel(startStory, false, null, null, repository) testScheduler.triggerActions() // WHEN @@ -92,7 +92,7 @@ class StoryViewerViewModelTest { val stories: List = (1L..5L).map(RecipientId::from) val startStory = stories.last() whenever(repository.getStories(any())).doReturn(Single.just(stories)) - val testSubject = StoryViewerViewModel(startStory, false, repository) + val testSubject = StoryViewerViewModel(startStory, false, null, null, repository) testScheduler.triggerActions() // WHEN @@ -112,7 +112,7 @@ class StoryViewerViewModelTest { val stories: List = (1L..5L).map(RecipientId::from) val startStory = stories.first() whenever(repository.getStories(any())).doReturn(Single.just(stories)) - val testSubject = StoryViewerViewModel(startStory, false, repository) + val testSubject = StoryViewerViewModel(startStory, false, null, null, repository) testScheduler.triggerActions() // WHEN @@ -132,7 +132,7 @@ class StoryViewerViewModelTest { val stories: List = (1L..5L).map(RecipientId::from) val startStory = stories.first() whenever(repository.getStories(any())).doReturn(Single.just(stories)) - val testSubject = StoryViewerViewModel(startStory, false, repository) + val testSubject = StoryViewerViewModel(startStory, false, null, null, repository) testScheduler.triggerActions() // WHEN