Implement cross-fade for story thumb shared element animation.
This commit is contained in:
parent
cb63fe600c
commit
a894ba7a51
30 changed files with 629 additions and 120 deletions
|
@ -605,6 +605,10 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity
|
|||
finish();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMediaReady() {
|
||||
}
|
||||
|
||||
private class ViewPagerListener extends ExtendedOnPageChangedListener {
|
||||
|
||||
@Override
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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());
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -28,8 +28,6 @@ class VideoControlsDelegate {
|
|||
} else {
|
||||
playWhenReady[uri] = true
|
||||
}
|
||||
|
||||
this.player?.videoPlayer?.play()
|
||||
}
|
||||
|
||||
fun restart() {
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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<StoryTextPostModel> {
|
||||
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<StoryTextPostModel?> {
|
||||
return arrayOfNulls(size)
|
||||
}
|
||||
|
||||
fun parseFrom(messageRecord: MessageRecord): StoryTextPostModel {
|
||||
return parseFrom(
|
||||
|
@ -74,8 +100,7 @@ data class StoryTextPostModel(
|
|||
class Decoder : ResourceDecoder<StoryTextPostModel, Bitmap> {
|
||||
|
||||
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)
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<RecipientId> = 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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(StoryViewerViewModel(startRecipientId, onlyIncludeHiddenStories, repository)) as T
|
||||
return modelClass.cast(StoryViewerViewModel(startRecipientId, onlyIncludeHiddenStories, storyThumbTextModel, storyThumbUri, repository)) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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<Drawable> {
|
||||
override fun onLoadFailed(e: GlideException?, model: Any?, target: Target<Drawable>?, isFirstResource: Boolean): Boolean {
|
||||
onReady()
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onResourceReady(resource: Drawable?, model: Any?, target: Target<Drawable>?, 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()
|
||||
}
|
||||
}
|
|
@ -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<MediaPreviewFragment.Events>().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<MediaPreviewFragment.Events>().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<Callback>().setIsDisplayingLinkPreviewTooltip(true)
|
||||
|
|
|
@ -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<T>(
|
||||
private val onResourceReady: Runnable,
|
||||
private val onLoadFailed: Runnable
|
||||
) : RequestListener<T> {
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun <T> onEither(onEither: Runnable): ActionRequestListener<T> {
|
||||
return ActionRequestListener(onEither, onEither)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLoadFailed(e: GlideException?, model: Any?, target: Target<T>?, isFirstResource: Boolean): Boolean {
|
||||
onLoadFailed.run()
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onResourceReady(resource: T, model: Any?, target: Target<T>?, dataSource: DataSource?, isFirstResource: Boolean): Boolean {
|
||||
onResourceReady.run()
|
||||
return false
|
||||
}
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<merge xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipChildren="false"
|
||||
android:clipToPadding="false"
|
||||
tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout">
|
||||
|
||||
<com.google.android.material.imageview.ShapeableImageView
|
||||
android:id="@+id/source_image"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
app:layout_constraintDimensionRatio="48:72"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:shapeAppearance="@style/ShapeAppearanceOverlay.Signal.Story.Preview" />
|
||||
|
||||
<com.google.android.material.imageview.ShapeableImageView
|
||||
android:id="@+id/target_image"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:alpha="0"
|
||||
android:importantForAccessibility="no"
|
||||
app:shapeAppearance="@style/ShapeAppearanceOverlay.Signal.Story.Text" />
|
||||
|
||||
</merge>
|
|
@ -7,11 +7,4 @@
|
|||
android:id="@+id/story_text_post"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/story_text_post_shared_element_target"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@color/core_grey_80" />
|
||||
</FrameLayout>
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
android:layout_height="match_parent"
|
||||
tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout">
|
||||
|
||||
<com.google.android.material.imageview.ShapeableImageView
|
||||
<ImageView
|
||||
android:id="@+id/text_story_post_background"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
|
|
|
@ -21,7 +21,7 @@
|
|||
android:id="@+id/story_content_card"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:transitionName="story"
|
||||
app:cardBackgroundColor="@color/transparent"
|
||||
app:cardCornerRadius="18dp"
|
||||
app:cardElevation="0dp">
|
||||
|
||||
|
@ -38,12 +38,23 @@
|
|||
android:layout_height="match_parent"
|
||||
tools:background="@drawable/test_gradient" />
|
||||
|
||||
<org.thoughtcrime.securesms.stories.viewer.reply.StoriesSharedElementCrossFaderView
|
||||
android:id="@+id/story_content_crossfader"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipChildren="false"
|
||||
android:clipToPadding="false"
|
||||
android:transitionName="story"
|
||||
android:alpha="0" />
|
||||
|
||||
<View
|
||||
android:id="@+id/story_gradient_top"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="160dp"
|
||||
android:background="@drawable/story_gradient_top" />
|
||||
|
||||
<View
|
||||
android:id="@+id/story_gradient_bottom"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="160dp"
|
||||
android:layout_gravity="bottom"
|
||||
|
@ -85,27 +96,33 @@
|
|||
app:barrierDirection="top"
|
||||
app:constraint_referenced_ids="from,sender_avatar,group_avatar,more" />
|
||||
|
||||
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
|
||||
android:id="@+id/story_caption"
|
||||
<FrameLayout
|
||||
android:id="@+id/story_caption_container"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="95sp"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:layout_marginBottom="24dp"
|
||||
android:gravity="bottom"
|
||||
android:textAppearance="@style/Signal.Text.Body"
|
||||
android:textStyle="bold"
|
||||
app:autoSizeMaxTextSize="20sp"
|
||||
app:autoSizeMinTextSize="16sp"
|
||||
app:autoSizeStepGranularity="4sp"
|
||||
app:autoSizeTextType="uniform"
|
||||
app:layout_constrainedHeight="true"
|
||||
app:layout_constraintBottom_toTopOf="@id/story_from_barrier"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintVertical_bias="1"
|
||||
tools:text="Ugh." />
|
||||
app:layout_constraintVertical_bias="1">
|
||||
|
||||
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
|
||||
android:id="@+id/story_caption"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="95sp"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:layout_marginBottom="24dp"
|
||||
android:gravity="bottom"
|
||||
android:textAppearance="@style/Signal.Text.Body"
|
||||
android:textStyle="bold"
|
||||
app:autoSizeMaxTextSize="20sp"
|
||||
app:autoSizeMinTextSize="16sp"
|
||||
app:autoSizeStepGranularity="4sp"
|
||||
app:autoSizeTextType="uniform"
|
||||
tools:text="Ugh." />
|
||||
</FrameLayout>
|
||||
|
||||
<View
|
||||
android:id="@+id/story_large_caption_overlay"
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<transitionSet android:duration="200"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<transition class="org.thoughtcrime.securesms.animation.transitions.CrossfaderTransition" />
|
||||
<changeBounds/>
|
||||
<changeTransform/>
|
||||
<changeImageTransform/>
|
||||
|
|
|
@ -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<RecipientId> = (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<RecipientId> = (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<RecipientId> = (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<RecipientId> = (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<RecipientId> = (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
|
||||
|
|
Loading…
Add table
Reference in a new issue