Use context menu when selecting a message in chat.

This commit is contained in:
Rashad Sookram
2022-01-28 15:12:35 -05:00
committed by Cody Henthorne
parent d254d24d77
commit e4d43ade93
38 changed files with 1013 additions and 435 deletions

View File

@@ -0,0 +1,91 @@
package org.thoughtcrime.securesms.components.menu
import android.os.Build
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
/**
* Handles the setup and display of actions shown in a context menu.
*/
class ContextMenuList(recyclerView: RecyclerView, onItemClick: () -> Unit) {
private val mappingAdapter = MappingAdapter().apply {
registerFactory(DisplayItem::class.java, LayoutFactory({ ItemViewHolder(it, onItemClick) }, R.layout.signal_context_menu_item))
}
init {
recyclerView.apply {
adapter = mappingAdapter
layoutManager = LinearLayoutManager(context)
itemAnimator = null
}
}
fun setItems(items: List<ActionItem>) {
mappingAdapter.submitList(items.toAdapterItems())
}
private fun List<ActionItem>.toAdapterItems(): List<DisplayItem> {
return this.mapIndexed { index, item ->
val displayType: DisplayType = when {
this.size == 1 -> DisplayType.ONLY
index == 0 -> DisplayType.TOP
index == this.size - 1 -> DisplayType.BOTTOM
else -> DisplayType.MIDDLE
}
DisplayItem(item, displayType)
}
}
private data class DisplayItem(
val item: ActionItem,
val displayType: DisplayType
) : MappingModel<DisplayItem> {
override fun areItemsTheSame(newItem: DisplayItem): Boolean {
return this == newItem
}
override fun areContentsTheSame(newItem: DisplayItem): Boolean {
return this == newItem
}
}
private enum class DisplayType {
TOP, BOTTOM, MIDDLE, ONLY
}
private class ItemViewHolder(
itemView: View,
private val onItemClick: () -> Unit,
) : MappingViewHolder<DisplayItem>(itemView) {
val icon: ImageView = itemView.findViewById(R.id.signal_context_menu_item_icon)
val title: TextView = itemView.findViewById(R.id.signal_context_menu_item_title)
override fun bind(model: DisplayItem) {
icon.setImageResource(model.item.iconRes)
title.text = model.item.title
itemView.setOnClickListener {
model.item.action.run()
onItemClick()
}
if (Build.VERSION.SDK_INT >= 21) {
when (model.displayType) {
DisplayType.TOP -> itemView.setBackgroundResource(R.drawable.signal_context_menu_item_background_top)
DisplayType.BOTTOM -> itemView.setBackgroundResource(R.drawable.signal_context_menu_item_background_bottom)
DisplayType.MIDDLE -> itemView.setBackgroundResource(R.drawable.signal_context_menu_item_background_middle)
DisplayType.ONLY -> itemView.setBackgroundResource(R.drawable.signal_context_menu_item_background_only)
}
}
}
}
}

View File

@@ -6,18 +6,10 @@ import android.os.Build
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.PopupWindow
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.adapter.mapping.Factory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
/**
* A custom context menu that will show next to an anchor view and display several options. Basically a PopupMenu with custom UI and positioning rules.
@@ -42,9 +34,10 @@ class SignalContextMenu private constructor(
val context: Context = anchor.context
val mappingAdapter = MappingAdapter().apply {
registerFactory(DisplayItem::class.java, ItemViewHolderFactory())
}
private val contextMenuList = ContextMenuList(
recyclerView = contentView.findViewById(R.id.signal_context_menu_list),
onItemClick = { dismiss() },
)
init {
setBackgroundDrawable(ContextCompat.getDrawable(context, R.drawable.signal_context_menu_background))
@@ -59,13 +52,7 @@ class SignalContextMenu private constructor(
elevation = 20f
}
contentView.findViewById<RecyclerView>(R.id.signal_context_menu_list).apply {
adapter = mappingAdapter
layoutManager = LinearLayoutManager(context)
itemAnimator = null
}
mappingAdapter.submitList(items.toAdapterItems())
contextMenuList.setItems(items)
}
private fun show() {
@@ -97,7 +84,7 @@ class SignalContextMenu private constructor(
offsetY = baseOffsetY
} else if (menuTopBound > screenTopBound) {
offsetY = -(anchorRect.height() + contentView.measuredHeight + baseOffsetY)
mappingAdapter.submitList(items.reversed().toAdapterItems())
contextMenuList.setItems(items.reversed())
} else {
offsetY = -((anchorRect.height() / 2) + (contentView.measuredHeight / 2) + baseOffsetY)
}
@@ -122,65 +109,6 @@ class SignalContextMenu private constructor(
showAsDropDown(anchor, offsetX, offsetY)
}
private fun List<ActionItem>.toAdapterItems(): List<DisplayItem> {
return this.mapIndexed { index, item ->
val displayType: DisplayType = when {
this.size == 1 -> DisplayType.ONLY
index == 0 -> DisplayType.TOP
index == this.size - 1 -> DisplayType.BOTTOM
else -> DisplayType.MIDDLE
}
DisplayItem(item, displayType)
}
}
private data class DisplayItem(
val item: ActionItem,
val displayType: DisplayType
) : MappingModel<DisplayItem> {
override fun areItemsTheSame(newItem: DisplayItem): Boolean {
return this == newItem
}
override fun areContentsTheSame(newItem: DisplayItem): Boolean {
return this == newItem
}
}
private enum class DisplayType {
TOP, BOTTOM, MIDDLE, ONLY
}
private inner class ItemViewHolder(itemView: View) : MappingViewHolder<DisplayItem>(itemView) {
val icon: ImageView = itemView.findViewById(R.id.signal_context_menu_item_icon)
val title: TextView = itemView.findViewById(R.id.signal_context_menu_item_title)
override fun bind(model: DisplayItem) {
icon.setImageResource(model.item.iconRes)
title.text = model.item.title
itemView.setOnClickListener {
model.item.action.run()
dismiss()
}
if (Build.VERSION.SDK_INT >= 21) {
when (model.displayType) {
DisplayType.TOP -> itemView.setBackgroundResource(R.drawable.signal_context_menu_item_background_top)
DisplayType.BOTTOM -> itemView.setBackgroundResource(R.drawable.signal_context_menu_item_background_bottom)
DisplayType.MIDDLE -> itemView.setBackgroundResource(R.drawable.signal_context_menu_item_background_middle)
DisplayType.ONLY -> itemView.setBackgroundResource(R.drawable.signal_context_menu_item_background_only)
}
}
}
}
private inner class ItemViewHolderFactory : Factory<DisplayItem> {
override fun createViewHolder(parent: ViewGroup): MappingViewHolder<DisplayItem> {
return ItemViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.signal_context_menu_item, parent, false))
}
}
enum class HorizontalPosition {
START, END
}

View File

@@ -191,6 +191,27 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver {
}
}
/**
* Tells the Media service to resume playback of a given audio slide. If the audio slide is not
* currently paused, playback will be started from the beginning.
*
* @param audioSlideUri The Uri of the desired audio slide
* @param messageId The Message id of the given audio slide
*/
public void resumePlayback(@NonNull Uri audioSlideUri, long messageId) {
if (isCurrentTrack(audioSlideUri)) {
getMediaController().getTransportControls().play();
} else {
Bundle extras = new Bundle();
extras.putLong(EXTRA_MESSAGE_ID, messageId);
extras.putLong(EXTRA_THREAD_ID, -1L);
extras.putDouble(EXTRA_PROGRESS, 0.0);
extras.putBoolean(EXTRA_PLAY_SINGLE, true);
getMediaController().getTransportControls().playFromUri(audioSlideUri, extras);
}
}
/**
* Pauses playback if the given audio slide is playing.
*

View File

@@ -706,6 +706,11 @@ public class ConversationAdapter
return getBindable().canPlayContent();
}
@Override
public boolean shouldProjectContent() {
return getBindable().shouldProjectContent();
}
@Override
public @NonNull ProjectionList getColorizerProjections(@NonNull ViewGroup coordinateRoot) {
return getBindable().getColorizerProjections(coordinateRoot);

View File

@@ -0,0 +1,55 @@
package org.thoughtcrime.securesms.conversation
import android.content.Context
import android.os.Build
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.PopupWindow
import androidx.core.content.ContextCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.menu.ActionItem
import org.thoughtcrime.securesms.components.menu.ContextMenuList
/**
* The context menu shown after long pressing a message in ConversationActivity.
*/
class ConversationContextMenu(private val anchor: View, items: List<ActionItem>) : PopupWindow(
LayoutInflater.from(anchor.context).inflate(R.layout.signal_context_menu, null),
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
) {
val context: Context = anchor.context
private val contextMenuList = ContextMenuList(
recyclerView = contentView.findViewById(R.id.signal_context_menu_list),
onItemClick = { dismiss() },
)
init {
setBackgroundDrawable(ContextCompat.getDrawable(context, R.drawable.signal_context_menu_background))
isFocusable = false
isOutsideTouchable = true
if (Build.VERSION.SDK_INT >= 21) {
elevation = 20f
}
contextMenuList.setItems(items)
contentView.measure(
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
)
}
fun getMaxWidth(): Int = contentView.measuredWidth
fun getMaxHeight(): Int = contentView.measuredHeight
fun show(offsetX: Int, offsetY: Int) {
showAsDropDown(anchor, offsetX, offsetY, Gravity.TOP or Gravity.START)
}
}

View File

@@ -24,6 +24,7 @@ import android.annotation.SuppressLint;
import android.content.Context;
import android.content.Intent;
import android.content.res.Configuration;
import android.graphics.Bitmap;
import android.graphics.Color;
import android.graphics.Rect;
import android.net.Uri;
@@ -148,6 +149,7 @@ import org.thoughtcrime.securesms.stickers.StickerPackPreviewActivity;
import org.thoughtcrime.securesms.util.CachedInflater;
import org.thoughtcrime.securesms.util.CommunicationActions;
import org.thoughtcrime.securesms.util.HtmlUtil;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
import org.thoughtcrime.securesms.util.RemoteDeleteUtil;
import org.thoughtcrime.securesms.util.SaveAttachmentTask;
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
@@ -180,7 +182,6 @@ import java.util.Set;
import java.util.concurrent.ExecutionException;
import kotlin.Unit;
import kotlin.jvm.functions.Function1;
@SuppressLint("StaticFieldLeak")
public class ConversationFragment extends LoggingFragment implements MultiselectForwardFragment.Callback {
@@ -196,7 +197,6 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
private LiveRecipient recipient;
private long threadId;
private boolean isReacting;
private ActionMode actionMode;
private Locale locale;
private FrameLayout videoContainer;
@@ -768,7 +768,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
}
if (menuState.shouldShowSaveAttachmentAction()) {
items.add(new ActionItem(R.drawable.ic_save_24, getResources().getString(R.string.conversation_selection__menu_save), () -> {
items.add(new ActionItem(R.drawable.ic_save_24_tinted, getResources().getString(R.string.conversation_selection__menu_save), () -> {
handleSaveAttachment((MediaMmsMessageRecord) getSelectedConversationMessage().getMessageRecord());
actionMode.finish();
}));
@@ -810,19 +810,21 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
ViewUtil.animateIn(bottomActionBar, bottomActionBar.getEnterAnimation());
listener.onBottomActionBarVisibilityChanged(View.VISIBLE);
ViewKt.doOnPreDraw(bottomActionBar, new Function1<View, Unit>() {
@Override public Unit invoke(View view) {
if (view.getHeight() == 0 && view.getVisibility() == View.VISIBLE) {
ViewKt.doOnPreDraw(bottomActionBar, this);
return Unit.INSTANCE;
bottomActionBar.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() {
@Override
public boolean onPreDraw() {
if (bottomActionBar.getHeight() == 0 && bottomActionBar.getVisibility() == View.VISIBLE) {
return false;
}
int bottomPadding = view.getHeight() + (int) DimensionUnit.DP.toPixels(18);
bottomActionBar.getViewTreeObserver().removeOnPreDrawListener(this);
int bottomPadding = bottomActionBar.getHeight() + (int) DimensionUnit.DP.toPixels(18);
list.setPadding(list.getPaddingLeft(), list.getPaddingTop(), list.getPaddingRight(), bottomPadding);
list.scrollBy(0, -(bottomPadding - additionalScrollOffset));
return Unit.INSTANCE;
return false;
}
});
} else {
@@ -1314,12 +1316,14 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
void onForwardClicked();
void onMessageRequest(@NonNull MessageRequestViewModel viewModel);
void handleReaction(@NonNull ConversationMessage conversationMessage,
@NonNull Toolbar.OnMenuItemClickListener toolbarListener,
@NonNull ConversationReactionOverlay.OnActionSelectedListener onActionSelectedListener,
@NonNull SelectedConversationModel selectedConversationModel,
@NonNull ConversationReactionOverlay.OnHideListener onHideListener);
void onCursorChanged();
void onMessageWithErrorClicked(@NonNull MessageRecord messageRecord);
void onVoiceNotePause(@NonNull Uri uri);
void onVoiceNotePlay(@NonNull Uri uri, long messageId, double progress);
void onVoiceNoteResume(@NonNull Uri uri, long messageId);
void onVoiceNoteSeekTo(@NonNull Uri uri, double progress);
void onVoiceNotePlaybackSpeedChanged(@NonNull Uri uri, float speed);
void onRegisterVoiceNoteCallbacks(@NonNull Observer<VoiceNotePlaybackState> onPlaybackStartObserver);
@@ -1430,16 +1434,63 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
multiselectItemDecoration.setFocusedItem(new MultiselectPart.Message(item.getConversationMessage()));
list.invalidateItemDecorations();
isReacting = true;
reactionsShade.setVisibility(View.VISIBLE);
list.setLayoutFrozen(true);
listener.handleReaction(item.getConversationMessage(), new ReactionsToolbarListener(item.getConversationMessage()), () -> {
isReacting = false;
reactionsShade.setVisibility(View.GONE);
list.setLayoutFrozen(false);
WindowUtil.setLightStatusBarFromTheme(requireActivity());
clearFocusedItem();
});
if (itemView instanceof ConversationItem) {
Uri audioUri = getAudioUriForLongClick(messageRecord);
if (audioUri != null) {
listener.onVoiceNotePause(audioUri);
}
Bitmap videoBitmap = null;
int childAdapterPosition = list.getChildAdapterPosition(itemView);
final GiphyMp4ProjectionPlayerHolder mp4Holder;
if (childAdapterPosition != RecyclerView.NO_POSITION) {
mp4Holder = giphyMp4ProjectionRecycler.getCurrentHolder(childAdapterPosition);
if (mp4Holder != null) {
mp4Holder.pause();
videoBitmap = mp4Holder.getBitmap();
mp4Holder.hide();
}
} else {
mp4Holder = null;
}
ConversationItem conversationItem = (ConversationItem) itemView;
Bitmap bitmap = ConversationItemSelection.snapshotView(conversationItem, list, messageRecord, videoBitmap);
final ConversationItemBodyBubble bodyBubble = conversationItem.bodyBubble;
SelectedConversationModel selectedConversationModel = new SelectedConversationModel(bitmap,
itemView.getX(),
itemView.getY() + list.getTranslationY(),
bodyBubble.getX(),
bodyBubble.getWidth(),
audioUri,
messageRecord.isOutgoing());
bodyBubble.setVisibility(View.INVISIBLE);
listener.handleReaction(item.getConversationMessage(), new ReactionsToolbarListener(item.getConversationMessage()), selectedConversationModel, () -> {
reactionsShade.setVisibility(View.GONE);
list.setLayoutFrozen(false);
if (selectedConversationModel.getAudioUri() != null) {
listener.onVoiceNoteResume(selectedConversationModel.getAudioUri(), messageRecord.getId());
}
WindowUtil.setLightStatusBarFromTheme(requireActivity());
clearFocusedItem();
if (mp4Holder != null) {
mp4Holder.show();
mp4Holder.resume();
}
bodyBubble.setVisibility(View.VISIBLE);
});
}
} else {
clearFocusedItem();
((ConversationAdapter) list.getAdapter()).toggleSelection(item);
@@ -1449,6 +1500,20 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
}
}
@Nullable private Uri getAudioUriForLongClick(@NonNull MessageRecord messageRecord) {
VoiceNotePlaybackState playbackState = listener.getVoiceNoteMediaController().getVoiceNotePlaybackState().getValue();
if (playbackState == null || !playbackState.isPlaying()) {
return null;
}
if (!MessageRecordUtil.hasAudio(messageRecord) || !messageRecord.isMms()) {
return null;
}
Uri messageUri = ((MmsMessageRecord) messageRecord).getSlideDeck().getAudioSlide().getUri();
return playbackState.getUri().equals(messageUri) ? messageUri : null;
}
@Override
public void onQuoteClicked(MmsMessageRecord messageRecord) {
if (messageRecord.getQuote() == null) {
@@ -1867,7 +1932,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
}
}
private class ReactionsToolbarListener implements Toolbar.OnMenuItemClickListener {
private class ReactionsToolbarListener implements ConversationReactionOverlay.OnActionSelectedListener {
private final ConversationMessage conversationMessage;
@@ -1876,16 +1941,32 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
}
@Override
public boolean onMenuItemClick(MenuItem item) {
switch (item.getItemId()) {
case R.id.action_info: handleDisplayDetails(conversationMessage); return true;
case R.id.action_delete: handleDeleteMessages(conversationMessage.getMultiselectCollection().toSet()); return true;
case R.id.action_copy: handleCopyMessage(conversationMessage.getMultiselectCollection().toSet()); return true;
case R.id.action_reply: handleReplyMessage(conversationMessage); return true;
case R.id.action_multiselect: handleEnterMultiSelect(conversationMessage); return true;
case R.id.action_forward: handleForwardMessageParts(conversationMessage.getMultiselectCollection().toSet()); return true;
case R.id.action_download: handleSaveAttachment((MediaMmsMessageRecord) conversationMessage.getMessageRecord()); return true;
default: return false;
public void onActionSelected(@NonNull ConversationReactionOverlay.Action action) {
switch (action) {
case REPLY:
handleReplyMessage(conversationMessage);
break;
case FORWARD:
handleForwardMessageParts(conversationMessage.getMultiselectCollection().toSet());
break;
case RESEND:
handleResendMessage(conversationMessage.getMessageRecord());
break;
case DOWNLOAD:
handleSaveAttachment((MediaMmsMessageRecord) conversationMessage.getMessageRecord());
break;
case COPY:
handleCopyMessage(conversationMessage.getMultiselectCollection().toSet());
break;
case MULTISELECT:
handleEnterMultiSelect(conversationMessage);
break;
case VIEW_INFO:
handleDisplayDetails(conversationMessage);
break;
case DELETE:
handleDeleteMessages(conversationMessage.getMultiselectCollection().toSet());
break;
}
}
}

View File

@@ -160,6 +160,9 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
private static final Rect SWIPE_RECT = new Rect();
public static final float LONG_PRESS_SCALE_FACTOR = 0.95f;
private static final int SHRINK_BUBBLE_DELAY_MILLIS = 100;
private ConversationMessage conversationMessage;
private MessageRecord messageRecord;
private Optional<MessageRecord> nextMessageRecord;
@@ -224,6 +227,21 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
private float lastYDownRelativeToThis;
private ProjectionList colorizerProjections = new ProjectionList(3);
private final Runnable shrinkBubble = new Runnable() {
@Override
public void run() {
bodyBubble.animate()
.scaleX(LONG_PRESS_SCALE_FACTOR)
.scaleY(LONG_PRESS_SCALE_FACTOR)
.setUpdateListener(animation -> {
View parent = (View) getParent();
if (parent != null) {
parent.invalidate();
}
});
}
};
public ConversationItem(Context context) {
this(context, null);
}
@@ -343,6 +361,20 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
setGroupAuthorColor(messageRecord, hasWallpaper, colorizer);
}
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
if (ev.getAction() == MotionEvent.ACTION_DOWN) {
getHandler().postDelayed(shrinkBubble, SHRINK_BUBBLE_DELAY_MILLIS);
} else {
getHandler().removeCallbacks(shrinkBubble);
bodyBubble.animate()
.scaleX(1.0f)
.scaleY(1.0f);
}
return super.dispatchTouchEvent(ev);
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
if (ev.getAction() == MotionEvent.ACTION_DOWN) {
@@ -1737,7 +1769,11 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
@Override
public @NonNull Projection getGiphyMp4PlayableProjection(@NonNull ViewGroup recyclerView) {
if (mediaThumbnailStub != null && mediaThumbnailStub.isResolvable()) {
return Projection.relativeToParent(recyclerView, mediaThumbnailStub.require(), mediaThumbnailStub.require().getCorners())
ConversationItemThumbnail thumbnail = mediaThumbnailStub.require();
return Projection.relativeToParent(recyclerView, thumbnail, thumbnail.getCorners())
.scale(bodyBubble.getScaleX())
.translateX(Util.halfOffsetFromScale(thumbnail.getWidth(), bodyBubble.getScaleX()))
.translateY(Util.halfOffsetFromScale(thumbnail.getHeight(), bodyBubble.getScaleY()))
.translateY(getTranslationY())
.translateX(bodyBubble.getTranslationX())
.translateX(getTranslationX());
@@ -1754,6 +1790,11 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
return mediaThumbnailStub != null && mediaThumbnailStub.isResolvable() && canPlayContent;
}
@Override
public boolean shouldProjectContent() {
return canPlayContent() && bodyBubble.getVisibility() == VISIBLE;
}
@Override
public @NonNull ProjectionList getColorizerProjections(@NonNull ViewGroup coordinateRoot) {
colorizerProjections.clear();
@@ -1761,34 +1802,70 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
if (messageRecord.isOutgoing() &&
!hasNoBubble(messageRecord) &&
!messageRecord.isRemoteDelete() &&
bodyBubbleCorners != null)
bodyBubbleCorners != null &&
bodyBubble.getVisibility() == VISIBLE)
{
Projection bodyBubbleToRoot = Projection.relativeToParent(coordinateRoot, bodyBubble, bodyBubbleCorners).translateX(bodyBubble.getTranslationX());
Projection videoToBubble = bodyBubble.getVideoPlayerProjection();
float translationX = Util.halfOffsetFromScale(bodyBubble.getWidth(), bodyBubble.getScaleX());
float translationY = Util.halfOffsetFromScale(bodyBubble.getHeight(), bodyBubble.getScaleY());
if (videoToBubble != null) {
Projection videoToRoot = Projection.translateFromDescendantToParentCoords(videoToBubble, bodyBubble, coordinateRoot);
colorizerProjections.addAll(Projection.getCapAndTail(bodyBubbleToRoot, videoToRoot));
List<Projection> projections = Projection.getCapAndTail(bodyBubbleToRoot, videoToRoot);
if (!projections.isEmpty()) {
projections.get(0)
.scale(bodyBubble.getScaleX())
.translateX(translationX)
.translateY(translationY);
projections.get(1)
.scale(bodyBubble.getScaleX())
.translateX(translationX)
.translateY(-translationY);
}
colorizerProjections.addAll(projections);
} else {
colorizerProjections.add(bodyBubbleToRoot);
colorizerProjections.add(
bodyBubbleToRoot.scale(bodyBubble.getScaleX())
.translateX(translationX)
.translateY(translationY)
);
}
}
if (messageRecord.isOutgoing() &&
hasNoBubble(messageRecord) &&
hasWallpaper)
hasWallpaper &&
bodyBubble.getVisibility() == VISIBLE)
{
Projection footerProjection = getActiveFooter(messageRecord).getProjection(coordinateRoot);
ConversationItemFooter footer = getActiveFooter(messageRecord);
Projection footerProjection = footer.getProjection(coordinateRoot);
if (footerProjection != null) {
colorizerProjections.add(footerProjection.translateX(bodyBubble.getTranslationX()));
colorizerProjections.add(
footerProjection.translateX(bodyBubble.getTranslationX())
.scale(bodyBubble.getScaleX())
.translateX(Util.halfOffsetFromScale(footer.getWidth(), bodyBubble.getScaleX()))
.translateY(-Util.halfOffsetFromScale(footer.getHeight(), bodyBubble.getScaleY()))
);
}
}
if (!messageRecord.isOutgoing() &&
hasQuote(messageRecord) &&
quoteView != null)
quoteView != null &&
bodyBubble.getVisibility() == VISIBLE)
{
bodyBubble.setQuoteViewProjection(quoteView.getProjection(bodyBubble));
colorizerProjections.add(quoteView.getProjection(coordinateRoot).translateX(bodyBubble.getTranslationX() + this.getTranslationX()));
float bubbleOffsetFromScale = Util.halfOffsetFromScale(bodyBubble.getHeight(), bodyBubble.getScaleY());
Projection cProj = quoteView.getProjection(coordinateRoot)
.translateX(bodyBubble.getTranslationX() + this.getTranslationX() + Util.halfOffsetFromScale(quoteView.getWidth(), bodyBubble.getScaleX()))
.translateY(bubbleOffsetFromScale - quoteView.getY() + (quoteView.getY() * bodyBubble.getScaleY()))
.scale(bodyBubble.getScaleX());
colorizerProjections.add(cProj);
}
for (int i = 0; i < colorizerProjections.size(); i++) {

View File

@@ -0,0 +1,106 @@
package org.thoughtcrime.securesms.conversation
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Path
import android.view.View
import androidx.core.graphics.applyCanvas
import androidx.core.graphics.createBitmap
import androidx.core.graphics.withClip
import androidx.core.graphics.withTranslation
import androidx.recyclerview.widget.RecyclerView
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.util.hasNoBubble
object ConversationItemSelection {
@JvmStatic
fun snapshotView(
conversationItem: ConversationItem,
list: RecyclerView,
messageRecord: MessageRecord,
videoBitmap: Bitmap?,
): Bitmap {
val isOutgoing = messageRecord.isOutgoing
val hasNoBubble = messageRecord.hasNoBubble(conversationItem.context)
return snapshotMessage(
conversationItem = conversationItem,
list = list,
videoBitmap = videoBitmap,
drawConversationItem = !isOutgoing || hasNoBubble,
)
}
private fun snapshotMessage(
conversationItem: ConversationItem,
list: RecyclerView,
videoBitmap: Bitmap?,
drawConversationItem: Boolean,
): Bitmap {
val initialReactionVisibility = conversationItem.reactionsView.visibility
if (initialReactionVisibility == View.VISIBLE) {
conversationItem.reactionsView.visibility = View.INVISIBLE
}
val originalScale = conversationItem.bodyBubble.scaleX
conversationItem.bodyBubble.scaleX = 1.0f
conversationItem.bodyBubble.scaleY = 1.0f
val projections = conversationItem.getColorizerProjections(list)
val path = Path()
val yTranslation = -conversationItem.y
val mp4Projection = conversationItem.getGiphyMp4PlayableProjection(list)
var scaledVideoBitmap = videoBitmap
if (videoBitmap != null) {
scaledVideoBitmap = Bitmap.createScaledBitmap(
videoBitmap,
(videoBitmap.width / originalScale).toInt(),
(videoBitmap.height / originalScale).toInt(),
true
)
mp4Projection.translateY(yTranslation)
mp4Projection.applyToPath(path)
}
projections.use {
it.forEach { p ->
p.translateY(yTranslation)
p.applyToPath(path)
}
}
val distanceToBubbleBottom = conversationItem.bodyBubble.height + conversationItem.bodyBubble.y.toInt()
return createBitmap(conversationItem.width, distanceToBubbleBottom).applyCanvas {
if (drawConversationItem) {
draw(conversationItem)
}
withClip(path) {
withTranslation(y = yTranslation) {
list.draw(this)
if (scaledVideoBitmap != null) {
drawBitmap(scaledVideoBitmap, mp4Projection.x, mp4Projection.y - yTranslation, null)
}
}
}
}.also {
mp4Projection.release()
conversationItem.reactionsView.visibility = initialReactionVisibility
conversationItem.bodyBubble.scaleX = originalScale
conversationItem.bodyBubble.scaleY = originalScale
}
}
private fun Canvas.draw(conversationItem: ConversationItem) {
val bodyBubble = conversationItem.bodyBubble
withTranslation(bodyBubble.x, bodyBubble.y) {
bodyBubble.draw(this@draw)
}
}
}

View File

@@ -3664,12 +3664,13 @@ public class ConversationParentFragment extends Fragment
@Override
public void handleReaction(@NonNull ConversationMessage conversationMessage,
@NonNull Toolbar.OnMenuItemClickListener toolbarListener,
@NonNull ConversationReactionOverlay.OnActionSelectedListener onActionSelectedListener,
@NonNull SelectedConversationModel selectedConversationModel,
@NonNull ConversationReactionOverlay.OnHideListener onHideListener)
{
reactionDelegate.setOnToolbarItemClickedListener(toolbarListener);
reactionDelegate.setOnActionSelectedListener(onActionSelectedListener);
reactionDelegate.setOnHideListener(onHideListener);
reactionDelegate.show(requireActivity(), recipient.get(), conversationMessage, groupViewModel.isNonAdminInAnnouncementGroup());
reactionDelegate.show(requireActivity(), recipient.get(), conversationMessage, groupViewModel.isNonAdminInAnnouncementGroup(), selectedConversationModel);
}
@Override
@@ -3697,6 +3698,11 @@ public class ConversationParentFragment extends Fragment
voiceNoteMediaController.startConsecutivePlayback(uri, messageId, progress);
}
@Override
public void onVoiceNoteResume(@NonNull Uri uri, long messageId) {
voiceNoteMediaController.resumePlayback(uri, messageId);
}
@Override
public void onVoiceNoteSeekTo(@NonNull Uri uri, double progress) {
voiceNoteMediaController.seekToPosition(uri, progress);

View File

@@ -5,7 +5,6 @@ import android.graphics.PointF;
import android.view.MotionEvent;
import androidx.annotation.NonNull;
import androidx.appcompat.widget.Toolbar;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.recipients.Recipient;
@@ -24,7 +23,7 @@ final class ConversationReactionDelegate {
private final PointF lastSeenDownPoint = new PointF();
private ConversationReactionOverlay.OnReactionSelectedListener onReactionSelectedListener;
private Toolbar.OnMenuItemClickListener onToolbarItemClickedListener;
private ConversationReactionOverlay.OnActionSelectedListener onActionSelectedListener;
private ConversationReactionOverlay.OnHideListener onHideListener;
ConversationReactionDelegate(@NonNull Stub<ConversationReactionOverlay> overlayStub) {
@@ -38,9 +37,10 @@ final class ConversationReactionDelegate {
void show(@NonNull Activity activity,
@NonNull Recipient conversationRecipient,
@NonNull ConversationMessage conversationMessage,
boolean isNonAdminInAnnouncementGroup)
boolean isNonAdminInAnnouncementGroup,
@NonNull SelectedConversationModel selectedConversationModel)
{
resolveOverlay().show(activity, conversationRecipient, conversationMessage, lastSeenDownPoint, isNonAdminInAnnouncementGroup);
resolveOverlay().show(activity, conversationRecipient, conversationMessage, lastSeenDownPoint, isNonAdminInAnnouncementGroup, selectedConversationModel);
}
void hide() {
@@ -59,11 +59,11 @@ final class ConversationReactionDelegate {
}
}
void setOnToolbarItemClickedListener(@NonNull Toolbar.OnMenuItemClickListener onToolbarItemClickedListener) {
this.onToolbarItemClickedListener = onToolbarItemClickedListener;
void setOnActionSelectedListener(@NonNull ConversationReactionOverlay.OnActionSelectedListener onActionSelectedListener) {
this.onActionSelectedListener = onActionSelectedListener;
if (overlayStub.resolved()) {
overlayStub.get().setOnToolbarItemClickedListener(onToolbarItemClickedListener);
overlayStub.get().setOnActionSelectedListener(onActionSelectedListener);
}
}
@@ -99,7 +99,7 @@ final class ConversationReactionDelegate {
overlay.requestFitSystemWindows();
overlay.setOnHideListener(onHideListener);
overlay.setOnToolbarItemClickedListener(onToolbarItemClickedListener);
overlay.setOnActionSelectedListener(onActionSelectedListener);
overlay.setOnReactionSelectedListener(onReactionSelectedListener);
return overlay;

View File

@@ -2,34 +2,41 @@ package org.thoughtcrime.securesms.conversation;
import android.animation.Animator;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.app.Activity;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.PointF;
import android.graphics.Rect;
import android.graphics.drawable.BitmapDrawable;
import android.os.Build;
import android.util.AttributeSet;
import android.view.HapticFeedbackConstants;
import android.view.MenuItem;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.Window;
import android.view.animation.DecelerateInterpolator;
import android.view.animation.Interpolator;
import android.widget.RelativeLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.widget.Toolbar;
import androidx.annotation.RequiresApi;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.constraintlayout.widget.ConstraintSet;
import androidx.core.content.ContextCompat;
import androidx.core.view.ViewKt;
import androidx.vectordrawable.graphics.drawable.AnimatorInflaterCompat;
import com.annimon.stream.Stream;
import org.signal.core.util.DimensionUnit;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
import org.thoughtcrime.securesms.components.emoji.EmojiImageView;
import org.thoughtcrime.securesms.components.emoji.EmojiUtil;
import org.thoughtcrime.securesms.components.menu.ActionItem;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.ReactionRecord;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
@@ -39,12 +46,15 @@ import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.WindowUtil;
import java.util.LinkedList;
import java.util.ArrayList;
import java.util.List;
import kotlin.Unit;
public final class ConversationReactionOverlay extends RelativeLayout {
private static final Interpolator INTERPOLATOR = new DecelerateInterpolator();
private static final Interpolator INTERPOLATOR = new DecelerateInterpolator();
private static final long TRANSITION_Y_DURATION = 150;
private final Rect emojiViewGlobalRect = new Rect();
private final Rect emojiStripViewBounds = new Rect();
@@ -54,23 +64,29 @@ public final class ConversationReactionOverlay extends RelativeLayout {
private final Boundary verticalScrubBoundary = new Boundary();
private final PointF deadzoneTouchPoint = new PointF();
private Activity activity;
private Recipient conversationRecipient;
private MessageRecord messageRecord;
private OverlayState overlayState = OverlayState.HIDDEN;
private boolean isNonAdminInAnnouncementGroup;
private Activity activity;
private Recipient conversationRecipient;
private MessageRecord messageRecord;
private SelectedConversationModel selectedConversationModel;
private OverlayState overlayState = OverlayState.HIDDEN;
private boolean isNonAdminInAnnouncementGroup;
private boolean downIsOurs;
private boolean isToolbarTouch;
private int selected = -1;
private int customEmojiIndex;
private int originalStatusBarColor;
private int originalNavigationBarColor;
private View dropdownAnchor;
private View toolbarShade;
private View inputShade;
private View conversationItem;
private View backgroundView;
private ConstraintLayout foregroundView;
private View selectedView;
private EmojiImageView[] emojiViews;
private Toolbar toolbar;
private ConversationContextMenu contextMenu;
private float touchDownDeadZoneSize;
private float distanceFromTouchDownPointToTopOfScrubberDeadZone;
@@ -78,21 +94,18 @@ public final class ConversationReactionOverlay extends RelativeLayout {
private int scrubberDistanceFromTouchDown;
private int scrubberHeight;
private int scrubberWidth;
private int actionBarHeight;
private int selectedVerticalTranslation;
private int scrubberHorizontalMargin;
private int animationEmojiStartDelayFactor;
private int statusBarHeight;
private OnReactionSelectedListener onReactionSelectedListener;
private Toolbar.OnMenuItemClickListener onToolbarItemClickedListener;
private OnActionSelectedListener onActionSelectedListener;
private OnHideListener onHideListener;
private AnimatorSet revealAnimatorSet = new AnimatorSet();
private AnimatorSet revealMaskAnimatorSet = new AnimatorSet();
private AnimatorSet hideAnimatorSet = new AnimatorSet();
private AnimatorSet hideAllButMaskAnimatorSet = new AnimatorSet();
private AnimatorSet hideMaskAnimatorSet = new AnimatorSet();
private AnimatorSet revealAnimatorSet = new AnimatorSet();
private AnimatorSet hideAnimatorSet = new AnimatorSet();
private List<Animator> hideAnimators;
public ConversationReactionOverlay(@NonNull Context context) {
super(context);
@@ -106,13 +119,13 @@ public final class ConversationReactionOverlay extends RelativeLayout {
protected void onFinishInflate() {
super.onFinishInflate();
backgroundView = findViewById(R.id.conversation_reaction_scrubber_background);
foregroundView = findViewById(R.id.conversation_reaction_scrubber_foreground);
selectedView = findViewById(R.id.conversation_reaction_current_selection_indicator);
toolbar = findViewById(R.id.conversation_reaction_toolbar);
toolbar.setOnMenuItemClickListener(this::handleToolbarItemClicked);
toolbar.setNavigationOnClickListener(view -> hide());
dropdownAnchor = findViewById(R.id.dropdown_anchor);
toolbarShade = findViewById(R.id.toolbar_shade);
inputShade = findViewById(R.id.input_shade);
conversationItem = findViewById(R.id.conversation_item);
backgroundView = findViewById(R.id.conversation_reaction_scrubber_background);
foregroundView = findViewById(R.id.conversation_reaction_scrubber_foreground);
selectedView = findViewById(R.id.conversation_reaction_current_selection_indicator);
emojiViews = new EmojiImageView[] { findViewById(R.id.reaction_1),
findViewById(R.id.reaction_2),
@@ -131,7 +144,6 @@ public final class ConversationReactionOverlay extends RelativeLayout {
scrubberDistanceFromTouchDown = getResources().getDimensionPixelOffset(R.dimen.conversation_reaction_scrubber_distance);
scrubberHeight = getResources().getDimensionPixelOffset(R.dimen.conversation_reaction_scrubber_height);
scrubberWidth = getResources().getDimensionPixelOffset(R.dimen.reaction_scrubber_width);
actionBarHeight = (int) ThemeUtil.getThemedDimen(getContext(), R.attr.actionBarSize);
selectedVerticalTranslation = getResources().getDimensionPixelOffset(R.dimen.conversation_reaction_scrub_vertical_translation);
scrubberHorizontalMargin = getResources().getDimensionPixelOffset(R.dimen.conversation_reaction_scrub_horizontal_margin);
@@ -144,7 +156,8 @@ public final class ConversationReactionOverlay extends RelativeLayout {
@NonNull Recipient conversationRecipient,
@NonNull ConversationMessage conversationMessage,
@NonNull PointF lastSeenDownPoint,
boolean isNonAdminInAnnouncementGroup)
boolean isNonAdminInAnnouncementGroup,
@NonNull SelectedConversationModel selectedConversationModel)
{
if (overlayState != OverlayState.HIDDEN) {
return;
@@ -152,11 +165,11 @@ public final class ConversationReactionOverlay extends RelativeLayout {
this.messageRecord = conversationMessage.getMessageRecord();
this.conversationRecipient = conversationRecipient;
this.selectedConversationModel = selectedConversationModel;
this.isNonAdminInAnnouncementGroup = isNonAdminInAnnouncementGroup;
overlayState = OverlayState.UNINITAILIZED;
selected = -1;
setupToolbarMenuItems(conversationMessage);
setupSelectedEmoji();
if (Build.VERSION.SDK_INT >= 21) {
@@ -166,63 +179,260 @@ public final class ConversationReactionOverlay extends RelativeLayout {
statusBarHeight = ViewUtil.getStatusBarHeight(this);
}
final float scrubberTranslationY = Math.max(-scrubberDistanceFromTouchDown + actionBarHeight,
lastSeenDownPoint.y - scrubberHeight - scrubberDistanceFromTouchDown - statusBarHeight);
ViewGroup.LayoutParams layoutParams = inputShade.getLayoutParams();
layoutParams.height = activity.findViewById(R.id.bottom_panel).getHeight();
inputShade.setLayoutParams(layoutParams);
final float halfWidth = scrubberWidth / 2f + scrubberHorizontalMargin;
final float screenWidth = getResources().getDisplayMetrics().widthPixels;
final float downX = ViewUtil.isLtr(this) ? lastSeenDownPoint.x : screenWidth - lastSeenDownPoint.x;
final float scrubberTranslationX = Util.clamp(downX - halfWidth,
scrubberHorizontalMargin,
screenWidth + scrubberHorizontalMargin - halfWidth * 2) * (ViewUtil.isLtr(this) ? 1 : -1);
toolbarShade.setVisibility(VISIBLE);
inputShade.setVisibility(VISIBLE);
backgroundView.setTranslationX(scrubberTranslationX);
backgroundView.setTranslationY(scrubberTranslationY);
Bitmap conversationItemSnapshot = selectedConversationModel.getBitmap();
foregroundView.setTranslationX(scrubberTranslationX);
foregroundView.setTranslationY(scrubberTranslationY);
conversationItem.setLayoutParams(new LayoutParams(conversationItemSnapshot.getWidth(), conversationItemSnapshot.getHeight()));
conversationItem.setBackground(new BitmapDrawable(getResources(), conversationItemSnapshot));
verticalScrubBoundary.update(lastSeenDownPoint.y - distanceFromTouchDownPointToTopOfScrubberDeadZone,
lastSeenDownPoint.y + distanceFromTouchDownPointToBottomOfScrubberDeadZone);
float initialX = selectedConversationModel.getBitmapX();
boolean isMessageOnLeft = selectedConversationModel.isOutgoing() ^ ViewUtil.isLtr(this);
conversationItem.setX(initialX);
conversationItem.setY(selectedConversationModel.getBitmapY() - statusBarHeight);
conversationItem.setScaleX(ConversationItem.LONG_PRESS_SCALE_FACTOR);
conversationItem.setScaleY(ConversationItem.LONG_PRESS_SCALE_FACTOR);
setVisibility(View.INVISIBLE);
ViewKt.doOnLayout(this, v -> {
showAfterLayout(activity, conversationMessage, lastSeenDownPoint, isMessageOnLeft);
return Unit.INSTANCE;
});
}
private void showAfterLayout(@NonNull Activity activity,
@NonNull ConversationMessage conversationMessage,
@NonNull PointF lastSeenDownPoint,
boolean isMessageOnLeft) {
contextMenu = new ConversationContextMenu(dropdownAnchor, getMenuActionItems(conversationMessage));
Bitmap conversationItemSnapshot = selectedConversationModel.getBitmap();
boolean isWideLayout = contextMenu.getMaxWidth() + scrubberWidth < getWidth();
int bubbleWidth = selectedConversationModel.getBubbleWidth();
float endX = selectedConversationModel.getBitmapX();
float endY = conversationItem.getY();
float endApparentTop = endY;
float endScale = 1f;
float menuPadding = DimensionUnit.DP.toPixels(12f);
int reactionBarHeight = backgroundView.getHeight();
float reactionBarBackgroundY;
if (isWideLayout) {
boolean everythingFitsVertically = scrubberHeight + conversationItemSnapshot.getHeight() < getHeight();
if (everythingFitsVertically) {
boolean reactionBarFitsAboveItem = conversationItem.getY() > scrubberHeight;
if (reactionBarFitsAboveItem) {
reactionBarBackgroundY = conversationItem.getY() - menuPadding - reactionBarHeight;
} else {
endY = reactionBarHeight + menuPadding;
reactionBarBackgroundY = 0f;
}
} else {
float spaceAvailableForItem = getHeight() - reactionBarHeight - menuPadding;
endScale = spaceAvailableForItem / conversationItem.getHeight();
endY = reactionBarHeight + menuPadding - Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale);
reactionBarBackgroundY = 0f;
}
} else {
boolean everythingFitsVertically = contextMenu.getMaxHeight() + conversationItemSnapshot.getHeight() + menuPadding + reactionBarHeight < getHeight();
if (everythingFitsVertically) {
float itemBottom = selectedConversationModel.getBitmapY() + conversationItemSnapshot.getHeight();
boolean menuFitsBelowItem = itemBottom + menuPadding + contextMenu.getMaxHeight() <= getHeight();
if (menuFitsBelowItem) {
reactionBarBackgroundY = conversationItem.getY() - menuPadding - reactionBarHeight;
if (reactionBarBackgroundY < 0) {
endY = backgroundView.getHeight();
reactionBarBackgroundY = 0f;
}
} else {
endY = getHeight() - contextMenu.getMaxHeight() - menuPadding - conversationItemSnapshot.getHeight();
reactionBarBackgroundY = endY - menuPadding - reactionBarHeight;
}
endApparentTop = endY;
} else if (reactionBarHeight + contextMenu.getMaxHeight() + menuPadding < getHeight()) {
float spaceAvailableForItem = (float) getHeight() - contextMenu.getMaxHeight() - menuPadding - reactionBarHeight;
endScale = spaceAvailableForItem / conversationItemSnapshot.getHeight();
endY = reactionBarHeight - Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale);
reactionBarBackgroundY = 0f;
endApparentTop = reactionBarHeight;
} else {
contextMenu.setHeight(contextMenu.getMaxHeight() / 2);
int menuHeight = contextMenu.getHeight();
boolean fitsVertically = menuHeight + conversationItem.getHeight() + menuPadding + reactionBarHeight < getHeight();
if (fitsVertically) {
endY = getHeight() - menuHeight - menuPadding - conversationItemSnapshot.getHeight();
reactionBarBackgroundY = endY - reactionBarHeight;
endApparentTop = endY;
} else {
float spaceAvailableForItem = (float) getHeight() - menuHeight - menuPadding - reactionBarHeight;
endScale = spaceAvailableForItem / conversationItemSnapshot.getHeight();
endY = reactionBarHeight - Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale);
reactionBarBackgroundY = 0f;
endApparentTop = reactionBarHeight;
}
}
}
reactionBarBackgroundY = Math.max(reactionBarBackgroundY, -statusBarHeight);
hideAnimatorSet.end();
toolbar.setVisibility(VISIBLE);
hideAnimatorSet = newHideAnimatorSet();
setVisibility(View.VISIBLE);
revealAnimatorSet.start();
if (Build.VERSION.SDK_INT >= 21) {
this.activity = activity;
originalStatusBarColor = activity.getWindow().getStatusBarColor();
WindowUtil.setStatusBarColor(activity.getWindow(), ContextCompat.getColor(getContext(), R.color.action_mode_status_bar));
updateSystemUiOnShow(activity);
}
if (!ThemeUtil.isDarkTheme(getContext())) {
WindowUtil.setLightStatusBar(activity.getWindow());
}
float scrubberX;
if (isMessageOnLeft) {
scrubberX = scrubberHorizontalMargin;
} else {
scrubberX = getWidth() - scrubberWidth - scrubberHorizontalMargin;
}
foregroundView.setX(scrubberX);
foregroundView.setY(reactionBarBackgroundY + reactionBarHeight / 2f - foregroundView.getHeight() / 2f);
backgroundView.setX(scrubberX);
backgroundView.setY(reactionBarBackgroundY);
verticalScrubBoundary.update(reactionBarBackgroundY,
lastSeenDownPoint.y + distanceFromTouchDownPointToBottomOfScrubberDeadZone);
updateBoundsOnLayoutChanged();
revealAnimatorSet.start();
if (isWideLayout) {
float scrubberRight = scrubberX + scrubberWidth;
float offsetX = isMessageOnLeft ? scrubberRight + menuPadding : scrubberX - contextMenu.getMaxWidth() - menuPadding;
contextMenu.show((int) offsetX, (int) Math.min(backgroundView.getY(), getHeight() - contextMenu.getMaxHeight()));
} else {
float contentX = selectedConversationModel.getContentX();
float offsetX = isMessageOnLeft ? contentX : - contextMenu.getMaxWidth() + contentX + bubbleWidth;
float menuTop = endApparentTop + (conversationItemSnapshot.getHeight() * endScale);
contextMenu.show((int) offsetX, (int) (menuTop + menuPadding));
}
conversationItem.animate()
.x(endX)
.scaleX(endScale)
.scaleY(endScale);
conversationItem.animate()
.y(endY)
.setDuration(TRANSITION_Y_DURATION);
}
@RequiresApi(api = 21)
private void updateSystemUiOnShow(@NonNull Activity activity) {
Window window = activity.getWindow();
int barColor = ContextCompat.getColor(getContext(), R.color.conversation_item_selected_system_ui);
originalStatusBarColor = window.getStatusBarColor();
WindowUtil.setStatusBarColor(window, barColor);
originalNavigationBarColor = window.getNavigationBarColor();
WindowUtil.setNavigationBarColor(window, barColor);
if (!ThemeUtil.isDarkTheme(getContext())) {
WindowUtil.clearLightStatusBar(window);
}
}
public void hide() {
hideInternal(hideAnimatorSet, onHideListener);
hideInternal(onHideListener);
}
public void hideForReactWithAny() {
hideInternal(hideAnimatorSet, null);
hideInternal(onHideListener);
}
private void hideInternal(@NonNull AnimatorSet hideAnimatorSet, @Nullable OnHideListener onHideListener) {
private void hideInternal(@Nullable OnHideListener onHideListener) {
overlayState = OverlayState.HIDDEN;
int duration = getContext().getResources().getInteger(R.integer.reaction_scrubber_reveal_duration);
List<Animator> hides = new ArrayList<>(hideAnimators);
ObjectAnimator itemScaleXAnim = new ObjectAnimator();
itemScaleXAnim.setProperty(View.SCALE_X);
itemScaleXAnim.setFloatValues(1f);
itemScaleXAnim.setTarget(conversationItem);
itemScaleXAnim.setDuration(duration);
hides.add(itemScaleXAnim);
ObjectAnimator itemScaleYAnim = new ObjectAnimator();
itemScaleYAnim.setProperty(View.SCALE_Y);
itemScaleYAnim.setFloatValues(1f);
itemScaleYAnim.setTarget(conversationItem);
itemScaleYAnim.setDuration(duration);
hides.add(itemScaleYAnim);
ObjectAnimator itemXAnim = new ObjectAnimator();
itemXAnim.setProperty(View.X);
itemXAnim.setFloatValues(selectedConversationModel.getBitmapX());
itemXAnim.setTarget(conversationItem);
itemXAnim.setDuration(duration);
hides.add(itemXAnim);
ObjectAnimator itemYAnim = new ObjectAnimator();
itemYAnim.setProperty(View.Y);
itemYAnim.setFloatValues(selectedConversationModel.getBitmapY() - statusBarHeight);
itemYAnim.setTarget(conversationItem);
itemYAnim.setDuration(TRANSITION_Y_DURATION);
hides.add(itemYAnim);
hideAnimatorSet.playTogether(hides);
revealAnimatorSet.end();
hideAnimatorSet.start();
if (Build.VERSION.SDK_INT >= 21 && activity != null) {
WindowUtil.setStatusBarColor(activity.getWindow(), originalStatusBarColor);
WindowUtil.clearLightStatusBar(activity.getWindow());
activity = null;
}
hideAnimatorSet.addListener(new AnimationCompleteListener() {
@Override public void onAnimationEnd(Animator animation) {
hideAnimatorSet.removeListener(this);
if (onHideListener != null) {
onHideListener.onHide();
toolbarShade.setVisibility(INVISIBLE);
inputShade.setVisibility(INVISIBLE);
if (Build.VERSION.SDK_INT >= 21 && activity != null) {
WindowUtil.setStatusBarColor(activity.getWindow(), originalStatusBarColor);
WindowUtil.setNavigationBarColor(activity.getWindow(), originalNavigationBarColor);
activity = null;
}
if (onHideListener != null) {
onHideListener.onHide();
}
}
});
if (contextMenu != null) {
contextMenu.dismiss();
}
}
@@ -238,6 +448,10 @@ public final class ConversationReactionOverlay extends RelativeLayout {
protected void onLayout(boolean changed, int l, int t, int r, int b) {
super.onLayout(changed, l, t, r, b);
updateBoundsOnLayoutChanged();
}
private void updateBoundsOnLayoutChanged() {
backgroundView.getGlobalVisibleRect(emojiStripViewBounds);
emojiViews[0].getGlobalVisibleRect(emojiViewGlobalRect);
emojiStripViewBounds.left = getStart(emojiViewGlobalRect);
@@ -300,24 +514,10 @@ public final class ConversationReactionOverlay extends RelativeLayout {
}
}
if (isToolbarTouch) {
if (motionEvent.getAction() == MotionEvent.ACTION_CANCEL || motionEvent.getAction() == MotionEvent.ACTION_UP) {
isToolbarTouch = false;
}
return false;
}
switch (motionEvent.getAction()) {
case MotionEvent.ACTION_DOWN:
selected = getSelectedIndexViaDownEvent(motionEvent);
if (selected == -1) {
if (motionEvent.getY() < toolbar.getHeight() + statusBarHeight) {
isToolbarTouch = true;
return false;
}
}
deadzoneTouchPoint.set(motionEvent.getX(), motionEvent.getY());
overlayState = OverlayState.DEADZONE;
downIsOurs = true;
@@ -454,8 +654,8 @@ public final class ConversationReactionOverlay extends RelativeLayout {
this.onReactionSelectedListener = onReactionSelectedListener;
}
public void setOnToolbarItemClickedListener(@Nullable Toolbar.OnMenuItemClickListener onToolbarItemClickedListener) {
this.onToolbarItemClickedListener = onToolbarItemClickedListener;
public void setOnActionSelectedListener(@Nullable OnActionSelectedListener onActionSelectedListener) {
this.onActionSelectedListener = onActionSelectedListener;
}
public void setOnHideListener(@Nullable OnHideListener onHideListener) {
@@ -474,24 +674,48 @@ public final class ConversationReactionOverlay extends RelativeLayout {
.orElse(null);
}
private void setupToolbarMenuItems(@NonNull ConversationMessage conversationMessage) {
private @NonNull List<ActionItem> getMenuActionItems(@NonNull ConversationMessage conversationMessage) {
MenuState menuState = MenuState.getMenuState(conversationRecipient, conversationMessage.getMultiselectCollection().toSet(), false, isNonAdminInAnnouncementGroup);
toolbar.getMenu().findItem(R.id.action_copy).setVisible(menuState.shouldShowCopyAction());
toolbar.getMenu().findItem(R.id.action_download).setVisible(menuState.shouldShowSaveAttachmentAction());
toolbar.getMenu().findItem(R.id.action_forward).setVisible(menuState.shouldShowForwardAction());
toolbar.getMenu().findItem(R.id.action_reply).setVisible(menuState.shouldShowReplyAction());
}
List<ActionItem> items = new ArrayList<>();
private boolean handleToolbarItemClicked(@NonNull MenuItem menuItem) {
hide();
if (onToolbarItemClickedListener == null) {
return false;
if (menuState.shouldShowReplyAction()) {
items.add(new ActionItem(R.drawable.ic_reply_24_tinted, getResources().getString(R.string.conversation_selection__menu_reply), () -> handleActionItemClicked(Action.REPLY)));
}
return onToolbarItemClickedListener.onMenuItemClick(menuItem);
if (menuState.shouldShowForwardAction()) {
items.add(new ActionItem(R.drawable.ic_forward_24_tinted, getResources().getString(R.string.conversation_selection__menu_forward), () -> handleActionItemClicked(Action.FORWARD)));
}
if (menuState.shouldShowResendAction()) {
items.add(new ActionItem(R.drawable.ic_retry_24, getResources().getString(R.string.conversation_selection__menu_resend_message), () -> handleActionItemClicked(Action.RESEND)));
}
if (menuState.shouldShowSaveAttachmentAction()) {
items.add(new ActionItem(R.drawable.ic_save_24_tinted, getResources().getString(R.string.conversation_selection__menu_save), () -> handleActionItemClicked(Action.DOWNLOAD)));
}
if (menuState.shouldShowCopyAction()) {
items.add(new ActionItem(R.drawable.ic_copy_24_tinted, getResources().getString(R.string.conversation_selection__menu_copy), () -> handleActionItemClicked(Action.COPY)));
}
items.add(new ActionItem(R.drawable.ic_select_24_tinted, getResources().getString(R.string.conversation_selection__menu_multi_select), () -> handleActionItemClicked(Action.MULTISELECT)));
items.add(new ActionItem(R.drawable.ic_info_tinted_24, getResources().getString(R.string.conversation_selection__menu_message_details), () -> handleActionItemClicked(Action.VIEW_INFO)));
items.add(new ActionItem(R.drawable.ic_delete_tinted_24, getResources().getString(R.string.conversation_selection__menu_delete), () -> handleActionItemClicked(Action.DELETE)));
return items;
}
private void handleActionItemClicked(@NonNull Action action) {
hideInternal(() -> {
if (onHideListener != null) {
onHideListener.onHide();
}
if (onActionSelectedListener != null) {
onActionSelectedListener.onActionSelected(action);
}
});
}
private void initAnimators() {
@@ -521,64 +745,47 @@ public final class ConversationReactionOverlay extends RelativeLayout {
selectedRevealAnim.setDuration(duration);
reveals.add(selectedRevealAnim);
Animator toolbarRevealAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_in);
toolbarRevealAnim.setTarget(toolbar);
toolbarRevealAnim.setDuration(duration);
reveals.add(toolbarRevealAnim);
revealAnimatorSet.setInterpolator(INTERPOLATOR);
revealAnimatorSet.playTogether(reveals);
revealMaskAnimatorSet.setInterpolator(INTERPOLATOR);
revealMaskAnimatorSet.playTogether(overlayRevealAnim);
List<Animator> hides = Stream.of(emojiViews)
.mapIndexed((idx, v) -> {
Animator anim = AnimatorInflaterCompat.loadAnimator(getContext(), R.animator.reactions_scrubber_hide);
anim.setTarget(v);
anim.setStartDelay(idx * animationEmojiStartDelayFactor);
return anim;
})
.toList();
hideAnimators = Stream.of(emojiViews)
.mapIndexed((idx, v) -> {
Animator anim = AnimatorInflaterCompat.loadAnimator(getContext(), R.animator.reactions_scrubber_hide);
anim.setTarget(v);
anim.setStartDelay(idx * animationEmojiStartDelayFactor);
return anim;
})
.toList();
Animator overlayHideAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_out);
overlayHideAnim.setDuration(duration);
hideAnimators.add(overlayHideAnim);
Animator backgroundHideAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_out);
backgroundHideAnim.setTarget(backgroundView);
backgroundHideAnim.setDuration(duration);
hides.add(backgroundHideAnim);
hideAnimators.add(backgroundHideAnim);
Animator selectedHideAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_out);
selectedHideAnim.setTarget(selectedView);
selectedHideAnim.setDuration(duration);
hides.add(selectedHideAnim);
hideAnimators.add(selectedHideAnim);
Animator toolbarHideAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_out);
toolbarHideAnim.setTarget(toolbar);
toolbarHideAnim.setDuration(duration);
hides.add(toolbarHideAnim);
hideAnimatorSet = newHideAnimatorSet();
}
AnimationCompleteListener hideListener = new AnimationCompleteListener() {
private @NonNull AnimatorSet newHideAnimatorSet() {
AnimatorSet set = new AnimatorSet();
set.addListener(new AnimationCompleteListener() {
@Override
public void onAnimationEnd(Animator animation) {
setVisibility(View.GONE);
}
};
});
set.setInterpolator(INTERPOLATOR);
List<Animator> hideAllAnimators = new LinkedList<>(hides);
hideAllAnimators.add(overlayHideAnim);
hideAnimatorSet.addListener(hideListener);
hideAnimatorSet.setInterpolator(INTERPOLATOR);
hideAnimatorSet.playTogether(hideAllAnimators);
hideAllButMaskAnimatorSet.setInterpolator(INTERPOLATOR);
hideAllButMaskAnimatorSet.playTogether(hides);
hideMaskAnimatorSet.addListener(hideListener);
hideMaskAnimatorSet.setInterpolator(INTERPOLATOR);
hideMaskAnimatorSet.playTogether(overlayHideAnim);
return set;
}
public interface OnHideListener {
@@ -590,6 +797,10 @@ public final class ConversationReactionOverlay extends RelativeLayout {
void onCustomReactionSelected(@NonNull MessageRecord messageRecord, boolean hasAddedCustomEmoji);
}
public interface OnActionSelectedListener {
void onActionSelected(@NonNull Action action);
}
private static class Boundary {
private float min;
private float max;
@@ -621,4 +832,15 @@ public final class ConversationReactionOverlay extends RelativeLayout {
SCRUB,
TAP
}
public enum Action {
REPLY,
FORWARD,
RESEND,
DOWNLOAD,
COPY,
MULTISELECT,
VIEW_INFO,
DELETE,
}
}

View File

@@ -220,6 +220,11 @@ public final class ConversationUpdateItem extends FrameLayout
return false;
}
@Override
public boolean shouldProjectContent() {
return false;
}
@Override
public @NonNull ProjectionList getColorizerProjections(@NonNull ViewGroup coordinateRoot) {
return EMPTY_PROJECTION_LIST;

View File

@@ -0,0 +1,18 @@
package org.thoughtcrime.securesms.conversation
import android.graphics.Bitmap
import android.net.Uri
/**
* Contains information on a single selected conversation item. This is used when transitioning
* between selected and unselected states.
*/
data class SelectedConversationModel(
val bitmap: Bitmap,
val bitmapX: Float,
val bitmapY: Float,
val contentX: Float,
val bubbleWidth: Int,
val audioUri: Uri? = null,
val isOutgoing: Boolean,
)

View File

@@ -79,6 +79,7 @@ class RecyclerViewColorizer(private val recyclerView: RecyclerView) {
}
private val colorPaint = Paint()
private val outOfBoundsPaint = Paint()
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
outRect.setEmpty()
@@ -122,6 +123,8 @@ class RecyclerViewColorizer(private val recyclerView: RecyclerView) {
mask.setXfermode(colorPaint.xfermode)
mask.setBounds(0, 0, parent.width, parent.height)
mask.draw(canvas)
outOfBoundsPaint.color = chatColors.getColors().last()
} else {
colorPaint.color = chatColors.asSingleColor()
canvas.drawRect(
@@ -131,7 +134,13 @@ class RecyclerViewColorizer(private val recyclerView: RecyclerView) {
parent.height.toFloat(),
colorPaint
)
outOfBoundsPaint.color = chatColors.asSingleColor()
}
canvas.drawRect(
0f, parent.height.toFloat(), parent.width.toFloat(), parent.height * 2f, outOfBoundsPaint
)
}
}

View File

@@ -362,7 +362,7 @@ class MultiselectItemDecoration(
}
}
if (child.canPlayContent()) {
if (child.canPlayContent() && child.shouldProjectContent()) {
val mp4GifProjection = child.getGiphyMp4PlayableProjection(child.rootView as ViewGroup)
path.op(mp4GifProjection.path, Path.Op.DIFFERENCE)
mp4GifProjection.release()

View File

@@ -51,4 +51,10 @@ public interface GiphyMp4Playable {
* Specifies whether the content can start playing.
*/
boolean canPlayContent();
/**
* Specifies whether the projection from {@link #getGiphyMp4PlayableProjection(ViewGroup)} should
* be used to project into a view.
*/
boolean shouldProjectContent();
}

View File

@@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.giph.mp4;
import android.content.Context;
import android.graphics.Bitmap;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
@@ -98,10 +99,18 @@ public final class GiphyMp4ProjectionPlayerHolder implements Player.Listener, De
container.setVisibility(View.GONE);
}
public void pause() {
player.pause();
}
public void show() {
container.setVisibility(View.VISIBLE);
}
public void resume() {
player.play();
}
@Override
public void onPlaybackStateChanged(int playbackState) {
if (playbackState == Player.STATE_READY) {
@@ -177,4 +186,8 @@ public final class GiphyMp4ProjectionPlayerHolder implements Player.Listener, De
public void setCorners(@Nullable Projection.Corners corners) {
player.setCorners(corners);
}
public @Nullable Bitmap getBitmap() {
return player.getBitmap();
}
}

View File

@@ -121,7 +121,7 @@ public final class GiphyMp4ProjectionRecycler implements GiphyMp4PlaybackControl
}
}
private @Nullable GiphyMp4ProjectionPlayerHolder getCurrentHolder(int adapterPosition) {
public @Nullable GiphyMp4ProjectionPlayerHolder getCurrentHolder(int adapterPosition) {
if (playing.get(adapterPosition) != null) {
return playing.get(adapterPosition);
} else if (notPlaying.get(adapterPosition) != null) {

View File

@@ -1,8 +1,11 @@
package org.thoughtcrime.securesms.giph.mp4;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.util.AttributeSet;
import android.view.TextureView;
import android.view.View;
import android.widget.FrameLayout;
import androidx.annotation.NonNull;
@@ -103,6 +106,12 @@ public final class GiphyMp4VideoPlayer extends FrameLayout implements DefaultLif
}
}
void pause() {
if (exoPlayer != null) {
exoPlayer.pause();
}
}
void stop() {
if (exoPlayer != null) {
exoPlayer.stop();
@@ -122,4 +131,13 @@ public final class GiphyMp4VideoPlayer extends FrameLayout implements DefaultLif
void setResizeMode(@AspectRatioFrameLayout.ResizeMode int resizeMode) {
exoView.setResizeMode(resizeMode);
}
@Nullable Bitmap getBitmap() {
final View view = exoView.getVideoSurfaceView();
if (view instanceof TextureView) {
return ((TextureView) view).getBitmap();
}
return null;
}
}

View File

@@ -89,6 +89,11 @@ final class GiphyMp4ViewHolder extends MappingViewHolder<GiphyImage> implements
return true;
}
@Override
public boolean shouldProjectContent() {
return true;
}
private void loadPlaceholderImage(@NonNull GiphyImage giphyImage) {
GlideApp.with(itemView)
.load(new ChunkedImageUrl(giphyImage.getStillUrl()))

View File

@@ -16,13 +16,11 @@ import androidx.annotation.Nullable;
import androidx.lifecycle.LifecycleOwner;
import androidx.recyclerview.widget.RecyclerView;
import com.annimon.stream.Stream;
import com.google.android.exoplayer2.MediaItem;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.concurrent.SignalExecutors;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.conversation.ClipProjectionDrawable;
import org.thoughtcrime.securesms.conversation.ConversationItem;
import org.thoughtcrime.securesms.conversation.ConversationMessage;
import org.thoughtcrime.securesms.conversation.colors.Colorizable;
@@ -41,10 +39,7 @@ import org.whispersystems.libsignal.util.guava.Optional;
import java.sql.Date;
import java.text.SimpleDateFormat;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.stream.Collectors;
final class MessageHeaderViewHolder extends RecyclerView.ViewHolder implements GiphyMp4Playable, Colorizable {
private final TextView sentDate;
@@ -250,6 +245,11 @@ final class MessageHeaderViewHolder extends RecyclerView.ViewHolder implements G
return conversationItem.canPlayContent();
}
@Override
public boolean shouldProjectContent() {
return conversationItem.shouldProjectContent();
}
@Override
public @NonNull ProjectionList getColorizerProjections(@NonNull ViewGroup coordinateRoot) {
return conversationItem.getColorizerProjections(coordinateRoot);

View File

@@ -114,6 +114,14 @@ public final class Projection {
return set(x, y + yTranslation, width, height, corners);
}
public @NonNull Projection scale(float scale) {
Corners newCorners = new Corners(this.corners.topLeft * scale,
this.corners.topRight * scale,
this.corners.bottomRight * scale,
this.corners.bottomLeft * scale);
return set(x, y, (int) (width * scale), (int) (height * scale), newCorners);
}
public static @NonNull Projection relativeToParent(@NonNull ViewGroup parent, @NonNull View view, @Nullable Corners corners) {
Rect viewBounds = new Rect();

View File

@@ -444,6 +444,15 @@ public class Util {
return Math.min(Math.max(value, min), max);
}
/**
* Returns half of the difference between the given length, and the length when scaled by the
* given scale.
*/
public static float halfOffsetFromScale(int length, float scale) {
float scaledLength = length * scale;
return (length - scaledLength) / 2;
}
public static @Nullable String readTextFromClipboard(@NonNull Context context) {
{
ClipboardManager clipboardManager = (ClipboardManager)context.getSystemService(Context.CLIPBOARD_SERVICE);

View File

@@ -35,6 +35,12 @@ public final class WindowUtil {
setSystemUiFlags(window, View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR);
}
public static void setNavigationBarColor(@NonNull Window window, @ColorInt int color) {
if (Build.VERSION.SDK_INT < 21) return;
window.setNavigationBarColor(color);
}
public static void setLightStatusBarFromTheme(@NonNull Activity activity) {
if (Build.VERSION.SDK_INT < 23) return;

View File

@@ -1,91 +0,0 @@
package org.thoughtcrime.securesms.util.views;
import android.content.Context;
import android.content.res.TypedArray;
import android.util.AttributeSet;
import android.view.Menu;
import android.view.MenuItem;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.widget.Toolbar;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.ViewUtil;
/**
* AdaptiveActionsToolbar behaves like a normal {@link Toolbar} except in that it ignores the
* showAsAlways attributes of menu items added via menu inflation, opting for an adaptive algorithm
* instead. This algorithm will display as many icons as it can up to a specific percentage of the
* screen.
*
* Each ActionView icon is expected to occupy 48dp of space, including padding. Items are stacked one
* after the next with no margins.
*
* This view can be customized via attributes:
*
* aat_max_shown -- controls the max number of items to display.
* aat_percent_for_actions -- controls the max percent of screen width the buttons can occupy.
*/
public class AdaptiveActionsToolbar extends Toolbar {
private static final int NAVIGATION_DP = 56;
private static final int ACTION_VIEW_WIDTH_DP = 48;
private static final int OVERFLOW_VIEW_WIDTH_DP = 36;
private int maxShown;
public AdaptiveActionsToolbar(@NonNull Context context) {
this(context, null);
}
public AdaptiveActionsToolbar(@NonNull Context context, @Nullable AttributeSet attrs) {
this(context, attrs, R.attr.toolbarStyle);
}
public AdaptiveActionsToolbar(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
TypedArray array = context.obtainStyledAttributes(attrs, R.styleable.AdaptiveActionsToolbar);
maxShown = array.getInteger(R.styleable.AdaptiveActionsToolbar_aat_max_shown, 100);
array.recycle();
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
adjustMenuActions(getMenu(), maxShown, getMeasuredWidth());
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
public static void adjustMenuActions(@NonNull Menu menu, int maxToShow, int toolbarWidthPx) {
int menuSize = 0;
for (int i = 0; i < menu.size(); i++) {
if (menu.getItem(i).isVisible()) {
menuSize++;
}
}
int widthAllowed = toolbarWidthPx - ViewUtil.dpToPx(NAVIGATION_DP);
int nItemsToShow = Math.min(maxToShow, widthAllowed / ViewUtil.dpToPx(ACTION_VIEW_WIDTH_DP));
if (nItemsToShow < menuSize) {
widthAllowed -= ViewUtil.dpToPx(OVERFLOW_VIEW_WIDTH_DP);
}
nItemsToShow = Math.min(maxToShow, widthAllowed / ViewUtil.dpToPx(ACTION_VIEW_WIDTH_DP));
for (int i = 0; i < menu.size(); i++) {
MenuItem item = menu.getItem(i);
if (item.isVisible() && nItemsToShow > 0) {
item.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS);
nItemsToShow--;
} else {
item.setShowAsAction(MenuItem.SHOW_AS_ACTION_NEVER);
}
}
}
}