Emoji Reactions Support (#645)

* Display Pleroma emoji reactions

* Interact with existing Pleroma emoji reactions

* Setting for emoji reaction support

* Setting for displaying reactions in timelines

* More horizontal padding on reactions display item

* List accounts who reacted

* Arbitrary emoji reaction from status footer

* Hide custom emoji keyboard when emoji is selected

* Clear preferences before applying
All preferences get written anyways so nothing will be lost

* Reset react visibility state on bind

* Fix custom emoji turning black when reacting

* Load reactions when a new one is added

* Emoji reactions grid

* Load custom emoji in reactions list fragment

* New reaction toast messages and Unicode emoji regex

* Make custom emoji picker for reactions scrollable

* Scroll down to show custom emoji picker when reacting

* Divider after reaction custom emoji picker

* Animate react button opacity back in

* fix plural strings

* re-implement reactions using horizontal recycler view

* update reactions with event

* tweak emoji font size

* tweak button styles (a tiny bit)

* change footer react button behavior

* fix emoji reaction status item padding

* move emoji reactions below content items

* add content description and tooltip

* use custom emoji keyboard to enter unicode emoji

* fix reactions clearing on status counter updates

* fix space next to emoji reactions not clickable

* make compatible with glitch-soc

* Remove now unused EmojiReactionsView class

* improve handling of reaction padding

---------

Co-authored-by: sk <sk22@mailbox.org>
This commit is contained in:
Jacoco
2023-08-18 18:14:33 +02:00
committed by GitHub
parent 1cdc58378a
commit a79779f813
26 changed files with 1163 additions and 173 deletions

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,267 @@
package org.joinmastodon.android.ui.displayitems;
import android.app.Activity;
import android.graphics.Paint;
import android.graphics.drawable.Animatable;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.os.Bundle;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import org.joinmastodon.android.E;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.api.requests.statuses.AddStatusReaction;
import org.joinmastodon.android.api.requests.statuses.DeleteStatusReaction;
import org.joinmastodon.android.api.requests.statuses.PleromaAddStatusReaction;
import org.joinmastodon.android.api.requests.statuses.PleromaDeleteStatusReaction;
import org.joinmastodon.android.events.StatusCountersUpdatedEvent;
import org.joinmastodon.android.fragments.BaseStatusListFragment;
import org.joinmastodon.android.fragments.account_list.StatusEmojiReactionsListFragment;
import org.joinmastodon.android.model.EmojiReaction;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.utils.TextDrawable;
import org.joinmastodon.android.ui.utils.UiUtils;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.imageloader.ImageLoaderRecyclerAdapter;
import me.grishka.appkit.imageloader.ImageLoaderViewHolder;
import me.grishka.appkit.imageloader.ListImageLoaderWrapper;
import me.grishka.appkit.imageloader.RecyclerViewDelegate;
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
import me.grishka.appkit.utils.BindableViewHolder;
import me.grishka.appkit.utils.V;
import me.grishka.appkit.views.UsableRecyclerView;
public class EmojiReactionsStatusDisplayItem extends StatusDisplayItem {
public final Status status;
private final Drawable placeholder;
private List<ImageLoaderRequest> requests;
public EmojiReactionsStatusDisplayItem(String parentID, BaseStatusListFragment<?> parentFragment, Status status) {
super(parentID, parentFragment);
this.status=status;
placeholder=parentFragment.getContext().getDrawable(R.drawable.image_placeholder).mutate();
placeholder.setBounds(0, 0, V.sp(24), V.sp(24));
}
private void refresh(Holder holder) {
requests=status.reactions.stream()
.map(e->e.url!=null ? new UrlImageLoaderRequest(e.url, V.sp(24), V.sp(24)) : null)
.collect(Collectors.toList());
holder.list.setPadding(holder.list.getPaddingLeft(),
status.reactions.isEmpty() ? 0 : V.dp(8), holder.list.getPaddingRight(), 0);
}
@Override
public int getImageCount(){
return (int) status.reactions.stream().filter(r->r.url != null).count();
}
@Override
public ImageLoaderRequest getImageRequest(int index){
return requests.get(index);
}
@Override
public Type getType(){
return Type.EMOJI_REACTIONS;
}
public static class Holder extends StatusDisplayItem.Holder<EmojiReactionsStatusDisplayItem> implements ImageLoaderViewHolder {
private final UsableRecyclerView list;
public Holder(Activity activity, ViewGroup parent) {
super(new UsableRecyclerView(activity) {
@Override
public boolean onTouchEvent(MotionEvent e){
super.onTouchEvent(e);
// to pass through touch events (i.e. clicking the status) to the parent view
return false;
}
});
list=(UsableRecyclerView) itemView;
list.setPadding(V.dp(12), 0, V.dp(12), 0);
list.setClipToPadding(false);
}
@Override
public void onBind(EmojiReactionsStatusDisplayItem item) {
ListImageLoaderWrapper imgLoader=new ListImageLoaderWrapper(item.parentFragment.getContext(), list, new RecyclerViewDelegate(list), null);
list.setAdapter(new EmojiReactionsAdapter(this, imgLoader));
list.setLayoutManager(new LinearLayoutManager(item.parentFragment.getContext(), LinearLayoutManager.HORIZONTAL, false));
item.refresh(this);
}
@Override
public void setImage(int index, Drawable image){
View child=list.getChildAt(index);
if(child==null) return;
((EmojiReactionViewHolder) list.getChildViewHolder(child)).setImage(index, image);
}
@Override
public void clearImage(int index){
setImage(index, item.placeholder);
}
private class EmojiReactionsAdapter extends UsableRecyclerView.Adapter<EmojiReactionViewHolder> implements ImageLoaderRecyclerAdapter{
RecyclerView list;
ListImageLoaderWrapper imgLoader;
Holder parentHolder;
public EmojiReactionsAdapter(Holder parentHolder, ListImageLoaderWrapper imgLoader){
super(imgLoader);
this.parentHolder=parentHolder;
this.imgLoader=imgLoader;
}
@Override
public void onAttachedToRecyclerView(@NonNull RecyclerView list){
super.onAttachedToRecyclerView(list);
this.list=list;
}
@NonNull
@Override
public EmojiReactionViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
Button btn=new Button(parent.getContext(), null, 0, R.style.Widget_Mastodon_M3_Button_Outlined_Icon);
ViewGroup.MarginLayoutParams params=new ViewGroup.MarginLayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
params.setMarginEnd(V.dp(8));
btn.setLayoutParams(params);
btn.setCompoundDrawableTintList(null);
btn.setBackgroundResource(R.drawable.bg_button_m3_tonal);
btn.setCompoundDrawables(item.placeholder, null, null, null);
return new EmojiReactionViewHolder(btn, item);
}
@Override
public void onBindViewHolder(EmojiReactionViewHolder holder, int position){
holder.bind(item.status.reactions.get(position));
super.onBindViewHolder(holder, position);
}
@Override
public int getItemCount(){
return item.status.reactions.size();
}
@Override
public int getImageCountForItem(int position){
return item.status.reactions.get(position).url == null ? 0 : 1;
}
@Override
public ImageLoaderRequest getImageRequest(int position, int image){
return item.requests.get(position);
}
}
private static class EmojiReactionViewHolder extends BindableViewHolder<EmojiReaction> implements ImageLoaderViewHolder{
private final Button btn;
private final EmojiReactionsStatusDisplayItem parent;
public EmojiReactionViewHolder(@NonNull View itemView, EmojiReactionsStatusDisplayItem parent){
super(itemView);
btn=(Button) itemView;
this.parent=parent;
}
@Override
public void setImage(int index, Drawable drawable){
drawable.setBounds(0, 0, V.sp(24), V.sp(24));
btn.setCompoundDrawablesRelative(drawable, null, null, null);
if(drawable instanceof Animatable) ((Animatable) drawable).start();
}
@Override
public void clearImage(int index){
setImage(index, parent.placeholder);
}
@Override
public void onBind(EmojiReaction item){
btn.setText(UiUtils.abbreviateNumber(item.count));
btn.setContentDescription(item.name);
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)btn.setTooltipText(item.name);
if(item.url==null){
Paint p=new Paint();
p.setTextSize(V.sp(18));
TextDrawable drawable=new TextDrawable(p, item.name);
btn.setCompoundDrawablesRelative(drawable, null, null, null);
}else{
btn.setCompoundDrawablesRelative(parent.placeholder, null, null, null);
}
btn.setSelected(item.me);
btn.setOnClickListener(e -> {
boolean deleting=item.me;
boolean ak=parent.parentFragment.isInstanceAkkoma();
MastodonAPIRequest<Status> req = deleting
? (ak ? new PleromaDeleteStatusReaction(parent.status.id, item.name) : new DeleteStatusReaction(parent.status.id, item.name))
: (ak ? new PleromaAddStatusReaction(parent.status.id, item.name) : new AddStatusReaction(parent.status.id, item.name));
req.setCallback(new Callback<>() {
@Override
public void onSuccess(Status result) {
List<EmojiReaction> oldList=new ArrayList<>(parent.status.reactions);
parent.status.reactions.clear();
parent.status.reactions.addAll(result.reactions);
EmojiReactionsAdapter adapter = (EmojiReactionsAdapter) getBindingAdapter();
// this handles addition/removal of new reactions
UiUtils.updateList(oldList, result.reactions, adapter.list, adapter,
(e1, e2) -> e1.name.equals(e2.name));
// update the existing reactions' counts
for(int i=0; i<result.reactions.size(); i++){
int index=i;
EmojiReaction newReaction=result.reactions.get(index);
oldList.stream().filter(r->r.name.equals(newReaction.name)).findAny().ifPresent(r->{
if(newReaction.count!=r.count) adapter.notifyItemChanged(index);
});
}
parent.refresh(adapter.parentHolder);
adapter.imgLoader.updateImages();
E.post(new StatusCountersUpdatedEvent(result, adapter.parentHolder));
}
@Override
public void onError(ErrorResponse error) {
error.showToast(itemView.getContext());
}
})
.exec(parent.parentFragment.getAccountID());
});
if (parent.parentFragment.isInstanceAkkoma()) {
// glitch-soc doesn't have this, afaik
btn.setOnLongClickListener(e->{
EmojiReaction emojiReaction=parent.status.reactions.stream().filter(r->r.name.equals(item.name)).findAny().orElseThrow();
Bundle args=new Bundle();
args.putString("account", parent.parentFragment.getAccountID());
args.putString("statusID", parent.status.id);
int atSymbolIndex = emojiReaction.name.indexOf("@");
args.putString("emoji", atSymbolIndex != -1 ? emojiReaction.name.substring(0, atSymbolIndex) : emojiReaction.name);
args.putString("url", emojiReaction.url);
args.putInt("count", emojiReaction.count);
Nav.go(parent.parentFragment.getActivity(), StatusEmojiReactionsListFragment.class, args);
return true;
});
}
}
}
}
}

View File

@@ -6,6 +6,9 @@ import android.content.Context;
import android.content.Intent;
import android.graphics.drawable.Drawable;
import android.os.Bundle;
import android.text.Editable;
import android.text.TextWatcher;
import android.util.DisplayMetrics;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.View;
@@ -14,26 +17,38 @@ import android.view.ViewGroup;
import android.view.accessibility.AccessibilityNodeInfo;
import android.view.animation.AlphaAnimation;
import android.view.animation.Animation;
import android.view.inputmethod.InputMethodManager;
import android.widget.Button;
import android.widget.EditText;
import android.widget.FrameLayout;
import android.widget.LinearLayout;
import android.widget.TextView;
import android.widget.Toast;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.api.requests.statuses.AddStatusReaction;
import org.joinmastodon.android.api.requests.statuses.PleromaAddStatusReaction;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.BaseStatusListFragment;
import org.joinmastodon.android.fragments.ComposeFragment;
import org.joinmastodon.android.model.Emoji;
import org.joinmastodon.android.model.Instance;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.model.StatusPrivacy;
import org.joinmastodon.android.ui.CustomEmojiPopupKeyboard;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.parceler.Parcels;
import java.util.function.Consumer;
import java.util.regex.Pattern;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.utils.CubicBezierInterpolator;
import me.grishka.appkit.utils.V;
@@ -54,8 +69,14 @@ public class FooterStatusDisplayItem extends StatusDisplayItem{
}
public static class Holder extends StatusDisplayItem.Holder<FooterStatusDisplayItem>{
private final FrameLayout reactLayout;
private final TextView replies, boosts, favorites;
private final View reply, boost, favorite, share, bookmark;
private final View reply, boost, favorite, share, bookmark, react;
private final InputMethodManager imm;
private CustomEmojiPopupKeyboard emojiKeyboard;
private LinearLayout emojiKeyboardContainer;
private boolean reactKeyboardVisible;
private final Activity activity;
private static final Animation opacityOut, opacityIn;
private View touchingView = null;
@@ -77,18 +98,25 @@ public class FooterStatusDisplayItem extends StatusDisplayItem{
}
};
private static final float ALPHA_PRESSED=0.55f;
static {
opacityOut = new AlphaAnimation(1, 0.55f);
opacityOut = new AlphaAnimation(1, ALPHA_PRESSED);
opacityOut.setDuration(300);
opacityOut.setInterpolator(CubicBezierInterpolator.DEFAULT);
opacityOut.setFillAfter(true);
opacityIn = new AlphaAnimation(0.55f, 1);
opacityIn = new AlphaAnimation(ALPHA_PRESSED, 1);
opacityIn.setDuration(400);
opacityIn.setInterpolator(CubicBezierInterpolator.DEFAULT);
}
public Holder(Activity activity, ViewGroup parent){
super(activity, R.layout.display_item_footer, parent);
this.activity = activity;
reactLayout=findViewById(R.id.react_layout);
emojiKeyboardContainer=findViewById(R.id.footer_emoji_keyboard_container);
replies=findViewById(R.id.reply);
boosts=findViewById(R.id.boost);
favorites=findViewById(R.id.favorite);
@@ -98,6 +126,7 @@ public class FooterStatusDisplayItem extends StatusDisplayItem{
favorite=findViewById(R.id.favorite_btn);
share=findViewById(R.id.share_btn);
bookmark=findViewById(R.id.bookmark_btn);
react=findViewById(R.id.react_btn);
reply.setOnTouchListener(this::onButtonTouch);
reply.setOnClickListener(this::onReplyClick);
@@ -111,6 +140,9 @@ public class FooterStatusDisplayItem extends StatusDisplayItem{
favorite.setOnClickListener(this::onFavoriteClick);
favorite.setOnLongClickListener(this::onFavoriteLongClick);
favorite.setAccessibilityDelegate(buttonAccessibilityDelegate);
react.setOnTouchListener(this::onButtonTouch);
react.setOnClickListener(this::onReactClick);
react.setAccessibilityDelegate(buttonAccessibilityDelegate);
bookmark.setOnTouchListener(this::onButtonTouch);
bookmark.setOnClickListener(this::onBookmarkClick);
bookmark.setOnLongClickListener(this::onBookmarkLongClick);
@@ -119,6 +151,8 @@ public class FooterStatusDisplayItem extends StatusDisplayItem{
share.setOnClickListener(this::onShareClick);
share.setOnLongClickListener(this::onShareLongClick);
share.setAccessibilityDelegate(buttonAccessibilityDelegate);
imm = (InputMethodManager) activity.getSystemService(Context.INPUT_METHOD_SERVICE);
}
@Override
@@ -135,6 +169,11 @@ public class FooterStatusDisplayItem extends StatusDisplayItem{
bookmark.setSelected(item.status.bookmarked);
boost.setEnabled(item.status.isReblogPermitted(item.accountID));
AccountSession accountSession=AccountSessionManager.get(item.accountID);
reactLayout.setVisibility(accountSession.getLocalPreferences().emojiReactionsEnabled
? View.VISIBLE
: View.GONE);
int nextPos = getAbsoluteAdapterPosition() + 1;
boolean nextIsWarning = item.parentFragment.getDisplayItems().size() > nextPos &&
item.parentFragment.getDisplayItems().get(nextPos) instanceof WarningFilteredStatusDisplayItem;
@@ -146,6 +185,28 @@ public class FooterStatusDisplayItem extends StatusDisplayItem{
condenseBottom ? V.dp(-5) : 0);
itemView.requestLayout();
reactKeyboardVisible=false;
emojiKeyboard=new CustomEmojiPopupKeyboard(activity, AccountSessionManager.getInstance().getCustomEmojis(accountSession.domain), accountSession.domain, true);
emojiKeyboard.setListener(new CustomEmojiPopupKeyboard.Listener(){
@Override
public void onEmojiSelected(Emoji emoji) {
addEmojiReaction(emoji.shortcode);
emojiKeyboard.toggleKeyboardPopup(null);
}
@Override
public void onEmojiSelected(String emoji){
addEmojiReaction(emoji);
emojiKeyboard.toggleKeyboardPopup(null);
}
@Override
public void onBackspace() {}
});
emojiKeyboardContainer.removeAllViews();
emojiKeyboardContainer.addView(emojiKeyboard.getView());
}
private void bindText(TextView btn, long count){
@@ -324,6 +385,29 @@ public class FooterStatusDisplayItem extends StatusDisplayItem{
return true;
}
private boolean resetReact(View v){
if(!reactKeyboardVisible) return false;
if(emojiKeyboard.isVisible()) emojiKeyboard.toggleKeyboardPopup(null);
reactKeyboardVisible=false;
v.setAlpha(1);
v.startAnimation(opacityIn);
return true;
}
private void onReactClick(View v){
if (resetReact(v)) return;
reactKeyboardVisible=true;
emojiKeyboard.toggleKeyboardPopup(null);
DisplayMetrics displayMetrics = new DisplayMetrics();
int[] locationOnScreen = new int[2];
activity.getWindowManager().getDefaultDisplay().getMetrics(displayMetrics);
v.getLocationOnScreen(locationOnScreen);
double fromScreenTop = (double) locationOnScreen[1] / displayMetrics.heightPixels;
if (fromScreenTop > 0.75) {
item.parentFragment.scrollBy(0, (int) (displayMetrics.heightPixels * 0.3));
}
}
private void onBookmarkClick(View v){
bookmark.setSelected(!item.status.bookmarked);
AccountSessionManager.getInstance().getAccount(item.accountID).getStatusInteractionController().setBookmarked(item.status, !item.status.bookmarked, r->{
@@ -369,7 +453,29 @@ public class FooterStatusDisplayItem extends StatusDisplayItem{
return R.string.add_bookmark;
if(id==R.id.share_btn)
return R.string.button_share;
if(id==R.id.react_btn)
return R.string.sk_button_react;
return 0;
}
private void addEmojiReaction(String emoji) {
MastodonAPIRequest<Status> req = item.parentFragment.isInstanceAkkoma()
? new PleromaAddStatusReaction(item.status.id, emoji)
: new AddStatusReaction(item.status.id, emoji);
req.setCallback(new Callback<>() {
@Override
public void onSuccess(Status result) {
item.parentFragment.updateEmojiReactions(result, getItemID());
}
@Override
public void onError(ErrorResponse error) {
error.showToast(item.parentFragment.getContext());
}
})
.exec(item.accountID);
reactKeyboardVisible=false;
react.startAnimation(opacityIn);
}
}
}

View File

@@ -64,6 +64,7 @@ public abstract class StatusDisplayItem{
public static final int FLAG_MEDIA_FORCE_HIDDEN=1 << 3;
public static final int FLAG_NO_HEADER=1 << 4;
public static final int FLAG_NO_TRANSLATE=1 << 5;
public static final int FLAG_NO_EMOJI_REACTIONS=1 << 6;
public void setAncestryInfo(
boolean hasDescendantNeighbor,
@@ -102,6 +103,7 @@ public abstract class StatusDisplayItem{
case POLL_OPTION -> new PollOptionStatusDisplayItem.Holder(activity, parent);
case POLL_FOOTER -> new PollFooterStatusDisplayItem.Holder(activity, parent);
case CARD -> new LinkCardStatusDisplayItem.Holder(activity, parent);
case EMOJI_REACTIONS -> new EmojiReactionsStatusDisplayItem.Holder(activity, parent);
case FOOTER -> new FooterStatusDisplayItem.Holder(activity, parent);
case ACCOUNT_CARD -> new AccountCardStatusDisplayItem.Holder(activity, parent);
case ACCOUNT -> new AccountStatusDisplayItem.Holder(new AccountViewHolder(parentFragment, parent, null));
@@ -118,7 +120,7 @@ public abstract class StatusDisplayItem{
};
}
public static ArrayList<StatusDisplayItem> buildItems(BaseStatusListFragment<?> fragment, Status status, String accountID, DisplayItemsParent parentObject, Map<String, Account> knownAccounts, boolean inset, boolean addFooter, boolean disableTranslate, FilterContext filterContext) {
public static ArrayList<StatusDisplayItem> buildItems(BaseStatusListFragment<?> fragment, Status status, String accountID, DisplayItemsParent parentObject, Map<String, Account> knownAccounts, boolean inset, boolean showReactions, boolean addFooter, boolean disableTranslate, FilterContext filterContext) {
int flags=0;
if(inset)
flags|=FLAG_INSET;
@@ -126,6 +128,8 @@ public abstract class StatusDisplayItem{
flags|=FLAG_NO_FOOTER;
if (disableTranslate)
flags|=FLAG_NO_TRANSLATE;
if (!showReactions)
flags|=FLAG_NO_EMOJI_REACTIONS;
return buildItems(fragment, status, accountID, parentObject, knownAccounts, filterContext, flags);
}
@@ -204,7 +208,7 @@ public abstract class StatusDisplayItem{
items.add(replyLine);
}
}
if((flags & FLAG_CHECKABLE)!=0)
items.add(header=new CheckableHeaderStatusDisplayItem(parentID, statusForContent.account, statusForContent.createdAt, fragment, accountID, statusForContent, null));
else
@@ -286,6 +290,9 @@ public abstract class StatusDisplayItem{
if(contentItems!=items && statusForContent.spoilerRevealed){
items.addAll(contentItems);
}
if((flags & FLAG_NO_EMOJI_REACTIONS)==0 && AccountSessionManager.get(accountID).getLocalPreferences().emojiReactionsEnabled){
items.add(new EmojiReactionsStatusDisplayItem(parentID, fragment, statusForContent));
}
if((flags & FLAG_NO_FOOTER)==0){
FooterStatusDisplayItem footer=new FooterStatusDisplayItem(parentID, fragment, statusForContent, accountID);
footer.hideCounts=hideCounts;
@@ -340,6 +347,7 @@ public abstract class StatusDisplayItem{
POLL_OPTION,
POLL_FOOTER,
CARD,
EMOJI_REACTIONS,
FOOTER,
ACCOUNT_CARD,
ACCOUNT,

View File

@@ -195,11 +195,13 @@ public class TextStatusDisplayItem extends StatusDisplayItem{
// remove additional padding when (transparently padded) translate button is visible
int nextPos = getAbsoluteAdapterPosition() + 1;
boolean nextIsFooter = item.parentFragment.getDisplayItems().size() > nextPos &&
item.parentFragment.getDisplayItems().get(nextPos) instanceof FooterStatusDisplayItem;
int bottomPadding = (translateVisible && nextIsFooter) ? 0
: nextIsFooter ? V.dp(6)
: V.dp(12);
int bottomPadding=V.dp(12);
if(item.parentFragment.getDisplayItems().size() > nextPos){
if(item.parentFragment.getDisplayItems().get(nextPos) instanceof FooterStatusDisplayItem) bottomPadding=V.dp(6);
if(item.parentFragment.getDisplayItems().get(nextPos) instanceof EmojiReactionsStatusDisplayItem){
bottomPadding=item.status.reactions.isEmpty() ? V.dp(6) : 0;
}
}
itemView.setPadding(itemView.getPaddingLeft(), itemView.getPaddingTop(), itemView.getPaddingRight(), bottomPadding);
if (!GlobalUserPreferences.collapseLongPosts) {

View File

@@ -0,0 +1,242 @@
package org.joinmastodon.android.ui.utils;
/*
* Copyright 2016 Ali Muzaffar
* <p/>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p/>
* http://www.apache.org/licenses/LICENSE-2.0
* <p/>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import android.graphics.Canvas;
import android.graphics.ColorFilter;
import android.graphics.Paint;
import android.graphics.PixelFormat;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.text.Editable;
import android.text.TextWatcher;
import android.widget.TextView;
import java.lang.ref.WeakReference;
public class TextDrawable extends Drawable implements TextWatcher {
private WeakReference<TextView> ref;
private String mText;
private Paint mPaint;
private Rect mHeightBounds;
private boolean mBindToViewPaint = false;
private float mPrevTextSize = 0;
private boolean mInitFitText = false;
private boolean mFitTextEnabled = false;
/**
* Create a TextDrawable using the given paint object and string
*
* @param paint
* @param s
*/
public TextDrawable(Paint paint, String s) {
mText = s;
mPaint = new Paint(paint);
mHeightBounds = new Rect();
init();
}
/**
* Create a TextDrawable. This uses the given TextView to initialize paint and has initial text
* that will be drawn. Initial text can also be useful for reserving space that may otherwise
* not be available when setting compound drawables.
*
* @param tv The TextView / EditText using to initialize this drawable
* @param initialText Optional initial text to display
* @param bindToViewsText Should this drawable mirror the text in the TextView
* @param bindToViewsPaint Should this drawable mirror changes to Paint in the TextView, like textColor, typeface, alpha etc.
* Note, this will override any changes made using setColorFilter or setAlpha.
*/
public TextDrawable(TextView tv, String initialText, boolean bindToViewsText, boolean bindToViewsPaint) {
this(tv.getPaint(), initialText);
ref = new WeakReference<>(tv);
if (bindToViewsText || bindToViewsPaint) {
if (bindToViewsText) {
tv.addTextChangedListener(this);
}
mBindToViewPaint = bindToViewsPaint;
}
}
/**
* Create a TextDrawable. This uses the given TextView to initialize paint and the text that
* will be drawn.
*
* @param tv The TextView / EditText using to initialize this drawable
* @param bindToViewsText Should this drawable mirror the text in the TextView
* @param bindToViewsPaint Should this drawable mirror changes to Paint in the TextView, like textColor, typeface, alpha etc.
* Note, this will override any changes made using setColorFilter or setAlpha.
*/
public TextDrawable(TextView tv, boolean bindToViewsText, boolean bindToViewsPaint) {
this(tv, tv.getText().toString(), bindToViewsText, bindToViewsPaint);
}
/**
* Use the provided TextView/EditText to initialize the drawable.
* The Drawable will copy the Text and the Paint properties, however it will from that
* point on be independant of the TextView.
*
* @param tv a TextView or EditText or any of their children.
*/
public TextDrawable(TextView tv) {
this(tv, false, false);
}
/**
* Use the provided TextView/EditText to initialize the drawable.
* The Drawable will copy the Paint properties, and use the provided text to initialise itself.
*
* @param tv a TextView or EditText or any of their children.
* @param s The String to draw
*/
public TextDrawable(TextView tv, String s) {
this(tv, s, false, false);
}
@Override
public void draw(Canvas canvas) {
if (mBindToViewPaint && ref.get() != null) {
Paint p = ref.get().getPaint();
canvas.drawText(mText, 0, getBounds().height(), p);
} else {
if (mInitFitText) {
fitTextAndInit();
}
canvas.drawText(mText, 0, getBounds().height(), mPaint);
}
}
@Override
public void setAlpha(int alpha) {
mPaint.setAlpha(alpha);
}
@Override
public void setColorFilter(ColorFilter colorFilter) {
mPaint.setColorFilter(colorFilter);
}
@Override
public int getOpacity() {
int alpha = mPaint.getAlpha();
if (alpha == 0) {
return PixelFormat.TRANSPARENT;
} else if (alpha == 255) {
return PixelFormat.OPAQUE;
} else {
return PixelFormat.TRANSLUCENT;
}
}
private void init() {
Rect bounds = getBounds();
//We want to use some character to determine the max height of the text.
//Otherwise if we draw something like "..." they will appear centered
//Here I'm just going to use the entire alphabet to determine max height.
mPaint.getTextBounds("1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 0, 1, mHeightBounds);
//This doesn't account for leading or training white spaces.
//mPaint.getTextBounds(mText, 0, mText.length(), bounds);
float width = mPaint.measureText(mText);
bounds.top = mHeightBounds.top;
bounds.bottom = mHeightBounds.bottom;
bounds.right = (int) width;
bounds.left = 0;
setBounds(bounds);
}
public void setPaint(Paint paint) {
mPaint = new Paint(paint);
//Since this can change the font used, we need to recalculate bounds.
if (mFitTextEnabled && !mInitFitText) {
fitTextAndInit();
} else {
init();
}
invalidateSelf();
}
public Paint getPaint() {
return mPaint;
}
public void setText(String text) {
mText = text;
//Since this can change the bounds of the text, we need to recalculate.
if (mFitTextEnabled && !mInitFitText) {
fitTextAndInit();
} else {
init();
}
invalidateSelf();
}
public String getText() {
return mText;
}
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
}
@Override
public void afterTextChanged(Editable s) {
setText(s.toString());
}
/**
* Make the TextDrawable match the width of the View it's associated with.
* <p/>
* Note: While this option will not work if bindToViewPaint is true.
*
* @param fitText
*/
public void setFillText(boolean fitText) {
mFitTextEnabled = fitText;
if (fitText) {
mPrevTextSize = mPaint.getTextSize();
if (ref.get() != null) {
if (ref.get().getWidth() > 0) {
fitTextAndInit();
} else {
mInitFitText = true;
}
}
} else {
if (mPrevTextSize > 0) {
mPaint.setTextSize(mPrevTextSize);
}
init();
}
}
private void fitTextAndInit() {
float fitWidth = ref.get().getWidth();
float textWidth = mPaint.measureText(mText);
float multi = fitWidth / textWidth;
mPaint.setTextSize(mPaint.getTextSize() * multi);
mInitFitText = false;
init();
}
}