Implement cross-fade for story thumb shared element animation.

This commit is contained in:
Alex Hart 2022-04-07 16:08:05 -03:00 committed by Cody Henthorne
parent cb63fe600c
commit a894ba7a51
30 changed files with 629 additions and 120 deletions

View file

@ -605,6 +605,10 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity
finish();
}
@Override
public void onMediaReady() {
}
private class ViewPagerListener extends ExtendedOnPageChangedListener {
@Override

View file

@ -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)
}
}

View file

@ -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);
}

View file

@ -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() {

View file

@ -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());

View file

@ -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;
}

View file

@ -28,8 +28,6 @@ class VideoControlsDelegate {
} else {
playWhenReady[uri] = true
}
this.player?.videoPlayer?.play()
}
fun restart() {

View file

@ -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();

View file

@ -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));
}
}

View file

@ -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)

View file

@ -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() {

View file

@ -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 = {

View file

@ -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 {

View file

@ -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 = {

View file

@ -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)
}
}
}

View file

@ -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)
}
}
}

View file

@ -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
}
}

View file

@ -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
}
}
}

View file

@ -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 {

View file

@ -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

View file

@ -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
}

View file

@ -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()
}
}

View file

@ -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)

View file

@ -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
}
}

View file

@ -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>

View file

@ -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>

View file

@ -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"

View file

@ -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"

View file

@ -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/>

View file

@ -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