Compose M3 redesign wip

This commit is contained in:
Grishka
2023-05-09 21:34:42 +03:00
parent 2b8451e045
commit 642e96a439
61 changed files with 2300 additions and 870 deletions

View File

@@ -1,10 +1,18 @@
package org.joinmastodon.android.fragments;
import android.app.Activity;
import android.content.res.TypedArray;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.media.MediaMetadataRetriever;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.view.Gravity;
import android.text.SpannableStringBuilder;
import android.text.style.BulletSpan;
import android.util.Log;
import android.view.ContextThemeWrapper;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
@@ -12,28 +20,34 @@ import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.view.inputmethod.InputMethodManager;
import android.widget.Button;
import android.widget.EditText;
import android.widget.FrameLayout;
import android.widget.ImageView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.statuses.UpdateAttachment;
import org.joinmastodon.android.api.MastodonAPIController;
import org.joinmastodon.android.model.Attachment;
import org.parceler.Parcels;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.photoviewer.PhotoViewer;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.views.FixedAspectRatioImageView;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.fragments.ToolbarFragment;
import java.util.Collections;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import me.grishka.appkit.fragments.OnBackPressedListener;
import me.grishka.appkit.imageloader.ViewImageLoader;
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
import me.grishka.appkit.utils.V;
public class ComposeImageDescriptionFragment extends MastodonToolbarFragment{
public class ComposeImageDescriptionFragment extends MastodonToolbarFragment implements OnBackPressedListener{
private static final String TAG="ComposeImageDescription";
private String accountID, attachmentID;
private EditText edit;
private Button saveButton;
private FixedAspectRatioImageView image;
private ContextThemeWrapper themeWrapper;
private PhotoViewer photoViewer;
@Override
public void onCreate(Bundle savedInstanceState){
@@ -46,7 +60,13 @@ public class ComposeImageDescriptionFragment extends MastodonToolbarFragment{
@Override
public void onAttach(Activity activity){
super.onAttach(activity);
setTitle(R.string.edit_image);
themeWrapper=new ContextThemeWrapper(activity, R.style.Theme_Mastodon_Dark);
setTitle(R.string.add_alt_text);
}
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState){
return super.onCreateView(themeWrapper.getSystemService(LayoutInflater.class), container, savedInstanceState);
}
@Override
@@ -54,14 +74,48 @@ public class ComposeImageDescriptionFragment extends MastodonToolbarFragment{
View view=inflater.inflate(R.layout.fragment_image_description, container, false);
edit=view.findViewById(R.id.edit);
ImageView image=view.findViewById(R.id.photo);
image=view.findViewById(R.id.photo);
int width=getArguments().getInt("width", 0);
int height=getArguments().getInt("height", 0);
if(width>0 && height>0){
image.setAspectRatio(Math.max(1f, (float)width/height));
}
image.setOnClickListener(v->openPhotoViewer());
Uri uri=getArguments().getParcelable("uri");
ViewImageLoader.load(image, null, new UrlImageLoaderRequest(uri, 1000, 1000));
Attachment.Type type=Attachment.Type.valueOf(getArguments().getString("attachmentType"));
if(type==Attachment.Type.IMAGE)
ViewImageLoader.load(image, null, new UrlImageLoaderRequest(uri, 1000, 1000));
else
loadVideoThumbIntoView(image, uri);
edit.setText(getArguments().getString("existingDescription"));
return view;
}
private void loadVideoThumbIntoView(ImageView target, Uri uri){
MastodonAPIController.runInBackground(()->{
Context context=getActivity();
if(context==null)
return;
try{
MediaMetadataRetriever mmr=new MediaMetadataRetriever();
mmr.setDataSource(context, uri);
Bitmap frame=mmr.getFrameAtTime(3_000_000);
mmr.release();
int size=Math.max(frame.getWidth(), frame.getHeight());
int maxSize=V.dp(250);
if(size>maxSize){
float factor=maxSize/(float)size;
frame=Bitmap.createScaledBitmap(frame, Math.round(frame.getWidth()*factor), Math.round(frame.getHeight()*factor), true);
}
Bitmap finalFrame=frame;
target.post(()->target.setImageBitmap(finalFrame));
}catch(Exception x){
Log.w(TAG, "loadVideoThumbIntoView: error getting video frame", x);
}
});
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
@@ -71,43 +125,114 @@ public class ComposeImageDescriptionFragment extends MastodonToolbarFragment{
@Override
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){
TypedArray ta=getActivity().obtainStyledAttributes(new int[]{R.attr.secondaryButtonStyle});
int buttonStyle=ta.getResourceId(0, 0);
ta.recycle();
saveButton=new Button(getActivity(), null, 0, buttonStyle);
saveButton.setText(R.string.save);
saveButton.setOnClickListener(this::onSaveClick);
FrameLayout wrap=new FrameLayout(getActivity());
wrap.addView(saveButton, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.TOP|Gravity.LEFT));
wrap.setPadding(V.dp(16), V.dp(4), V.dp(16), V.dp(8));
wrap.setClipToPadding(false);
MenuItem item=menu.add(R.string.publish);
item.setActionView(wrap);
item.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS);
inflater.inflate(R.menu.compose_image_description, menu);
}
@Override
public boolean onOptionsItemSelected(MenuItem item){
if(item.getItemId()==R.id.help){
SpannableStringBuilder msg=new SpannableStringBuilder(getText(R.string.alt_text_help));
BulletSpan[] spans=msg.getSpans(0, msg.length(), BulletSpan.class);
for(BulletSpan span:spans){
BulletSpan betterSpan;
if(Build.VERSION.SDK_INT<Build.VERSION_CODES.Q)
betterSpan=new BulletSpan(V.dp(10), UiUtils.getThemeColor(themeWrapper, R.attr.colorM3OnSurface));
else
betterSpan=new BulletSpan(V.dp(10), UiUtils.getThemeColor(themeWrapper, R.attr.colorM3OnSurface), V.dp(1.5f));
msg.setSpan(betterSpan, msg.getSpanStart(span), msg.getSpanEnd(span), msg.getSpanFlags(span));
msg.removeSpan(span);
}
new M3AlertDialogBuilder(themeWrapper)
.setTitle(R.string.what_is_alt_text)
.setMessage(msg)
.setPositiveButton(R.string.ok, null)
.show();
}
return true;
}
private void onSaveClick(View v){
new UpdateAttachment(attachmentID, edit.getText().toString().trim())
.setCallback(new Callback<>(){
@Override
public void onSuccess(Attachment result){
Bundle r=new Bundle();
r.putParcelable("attachment", Parcels.wrap(result));
setResult(true, r);
Nav.finish(ComposeImageDescriptionFragment.this);
}
@Override
public boolean onBackPressed(){
deliverResult();
return false;
}
@Override
public void onError(ErrorResponse error){
error.showToast(getActivity());
}
})
.wrapProgress(getActivity(), R.string.saving, false)
.exec(accountID);
@Override
protected LayoutInflater getToolbarLayoutInflater(){
return LayoutInflater.from(themeWrapper);
}
private void deliverResult(){
Bundle r=new Bundle();
r.putString("text", edit.getText().toString().trim());
r.putString("attachment", attachmentID);
setResult(true, r);
}
private void openPhotoViewer(){
Attachment fakeAttachment=new Attachment();
fakeAttachment.id="local";
fakeAttachment.type=Attachment.Type.valueOf(getArguments().getString("attachmentType"));
int width=getArguments().getInt("width", 0);
int height=getArguments().getInt("height", 0);
Uri uri=getArguments().getParcelable("uri");
fakeAttachment.url=uri.toString();
fakeAttachment.meta=new Attachment.Metadata();
fakeAttachment.meta.width=width;
fakeAttachment.meta.height=height;
photoViewer=new PhotoViewer(getActivity(), Collections.singletonList(fakeAttachment), 0, new PhotoViewer.Listener(){
@Override
public void setPhotoViewVisibility(int index, boolean visible){
image.setAlpha(visible ? 1f : 0f);
}
@Override
public boolean startPhotoViewTransition(int index, @NonNull Rect outRect, @NonNull int[] outCornerRadius){
int[] pos={0, 0};
image.getLocationOnScreen(pos);
outRect.set(pos[0], pos[1], pos[0]+image.getWidth(), pos[1]+image.getHeight());
image.setElevation(1f);
return true;
}
@Override
public void setTransitioningViewTransform(float translateX, float translateY, float scale){
image.setTranslationX(translateX);
image.setTranslationY(translateY);
image.setScaleX(scale);
image.setScaleY(scale);
}
@Override
public void endPhotoViewTransition(){
Drawable d=image.getDrawable();
image.setImageDrawable(null);
image.setImageDrawable(d);
image.setTranslationX(0f);
image.setTranslationY(0f);
image.setScaleX(1f);
image.setScaleY(1f);
image.setElevation(0f);
}
@Nullable
@Override
public Drawable getPhotoViewCurrentDrawable(int index){
return image.getDrawable();
}
@Override
public void photoViewerDismissed(){
photoViewer=null;
}
@Override
public void onRequestPermissions(String[] permissions){
}
});
photoViewer.removeMenu();
}
}

View File

@@ -11,6 +11,15 @@ import androidx.annotation.CallSuper;
import me.grishka.appkit.fragments.ToolbarFragment;
public abstract class MastodonToolbarFragment extends ToolbarFragment{
public MastodonToolbarFragment(){
super();
}
protected MastodonToolbarFragment(int layout){
super(layout);
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);

View File

@@ -387,6 +387,8 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
@Override
public void onPageScrollStateChanged(int state){
if(isInEditMode)
return;
refreshLayout.setEnabled(state!=ViewPager2.SCROLL_STATE_DRAGGING);
}
});
@@ -801,6 +803,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
bioEdit.setText(account.source.note);
aboutFragment.enterEditMode(account.source.fields);
refreshLayout.setEnabled(false);
}
private void exitEditMode(){
@@ -840,6 +843,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
username.setVisibility(View.VISIBLE);
bio.setVisibility(View.VISIBLE);
countersLayout.setVisibility(View.VISIBLE);
refreshLayout.setEnabled(true);
bindHeaderView();
}

View File

@@ -1,6 +1,8 @@
package org.joinmastodon.android.ui;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.graphics.Rect;
import android.graphics.Typeface;
import android.graphics.drawable.Drawable;
import android.text.TextUtils;
@@ -19,7 +21,6 @@ import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Emoji;
import org.joinmastodon.android.model.Hashtag;
import org.joinmastodon.android.model.SearchResults;
import org.joinmastodon.android.ui.drawables.ComposeAutocompleteBackgroundDrawable;
import org.joinmastodon.android.ui.text.HtmlParser;
import org.joinmastodon.android.ui.utils.CustomEmojiHelper;
import org.joinmastodon.android.ui.utils.UiUtils;
@@ -60,7 +61,6 @@ public class ComposeAutocompleteViewController{
private APIRequest currentRequest;
private Runnable usersDebouncer=this::doSearchUsers, hashtagsDebouncer=this::doSearchHashtags;
private String lastText;
private ComposeAutocompleteBackgroundDrawable background;
private boolean listIsHidden=true;
private UsersAdapter usersAdapter;
@@ -69,19 +69,25 @@ public class ComposeAutocompleteViewController{
private Consumer<String> completionSelectedListener;
private DividerItemDecoration usersDividers, hashtagsDividers;
public ComposeAutocompleteViewController(Activity activity, String accountID){
this.activity=activity;
this.accountID=accountID;
background=new ComposeAutocompleteBackgroundDrawable(UiUtils.getThemeColor(activity, android.R.attr.colorBackground));
contentView=new FrameLayout(activity);
contentView.setBackground(background);
list=new UsableRecyclerView(activity);
list.setLayoutManager(new LinearLayoutManager(activity));
list.setLayoutManager(new LinearLayoutManager(activity, LinearLayoutManager.HORIZONTAL, false));
list.setItemAnimator(new BetterItemAnimator());
list.setVisibility(View.GONE);
list.setPadding(V.dp(16), V.dp(12), V.dp(16), V.dp(12));
list.setClipToPadding(false);
list.setSelector(null);
list.addItemDecoration(new RecyclerView.ItemDecoration(){
@Override
public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){
if(parent.getChildAdapterPosition(view)<parent.getAdapter().getItemCount()-1)
outRect.right=V.dp(8);
}
});
contentView.addView(list, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
progress=new ProgressBar(activity);
@@ -89,9 +95,6 @@ public class ComposeAutocompleteViewController{
progressLP.topMargin=V.dp(16);
contentView.addView(progress, progressLP);
usersDividers=new DividerItemDecoration(activity, R.attr.colorPollVoted, 1, 72, 16);
hashtagsDividers=new DividerItemDecoration(activity, R.attr.colorPollVoted, 1, 16, 16);
imgLoader=new ListImageLoaderWrapper(activity, list, new RecyclerViewDelegate(list), null);
}
@@ -141,11 +144,6 @@ public class ComposeAutocompleteViewController{
progress.setVisibility(View.GONE);
listIsHidden=false;
}
if((prevMode==Mode.HASHTAGS)!=(mode==Mode.HASHTAGS) || prevMode==null){
if(prevMode!=null)
list.removeItemDecoration(prevMode==Mode.HASHTAGS ? hashtagsDividers : usersDividers);
list.addItemDecoration(mode==Mode.HASHTAGS ? hashtagsDividers : usersDividers);
}
}
lastText=text;
if(mode==Mode.USERS){
@@ -176,10 +174,6 @@ public class ComposeAutocompleteViewController{
this.completionSelectedListener=completionSelectedListener;
}
public void setArrowOffset(int offset){
background.setArrowOffset(offset);
}
public View getView(){
return contentView;
}
@@ -258,7 +252,7 @@ public class ComposeAutocompleteViewController{
@Override
public int getImageCountForItem(int position){
return 1+users.get(position).emojiHelper.getImageCount();
return 1/*+users.get(position).emojiHelper.getImageCount()*/;
}
@Override
@@ -272,20 +266,18 @@ public class ComposeAutocompleteViewController{
private class UserViewHolder extends BindableViewHolder<WrappedAccount> implements ImageLoaderViewHolder, UsableRecyclerView.Clickable{
private final ImageView ava;
private final TextView name, username;
private final TextView username;
private UserViewHolder(){
super(activity, R.layout.item_autocomplete_user, list);
ava=findViewById(R.id.photo);
name=findViewById(R.id.name);
username=findViewById(R.id.username);
ava.setOutlineProvider(OutlineProviders.roundedRect(12));
ava.setOutlineProvider(OutlineProviders.OVAL);
ava.setClipToOutline(true);
}
@Override
public void onBind(WrappedAccount item){
name.setText(item.parsedName);
username.setText("@"+item.account.acct);
}
@@ -300,7 +292,6 @@ public class ComposeAutocompleteViewController{
ava.setImageDrawable(image);
}else{
item.emojiHelper.setImageDrawable(index-1, image);
name.invalidate();
}
}
@@ -333,17 +324,11 @@ public class ComposeAutocompleteViewController{
private final TextView text;
private HashtagViewHolder(){
super(new TextView(activity));
super(activity, R.layout.item_autocomplete_hashtag, list);
text=(TextView) itemView;
text.setLayoutParams(new RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, V.dp(48)));
text.setTextAppearance(R.style.m3_title_medium);
text.setTypeface(Typeface.DEFAULT);
text.setSingleLine();
text.setEllipsize(TextUtils.TruncateAt.END);
text.setGravity(Gravity.CENTER_VERTICAL);
text.setPadding(V.dp(16), 0, V.dp(16), 0);
}
@SuppressLint("SetTextI18n")
@Override
public void onBind(Hashtag item){
text.setText("#"+item.name);
@@ -395,7 +380,7 @@ public class ComposeAutocompleteViewController{
private EmojiViewHolder(){
super(activity, R.layout.item_autocomplete_user, list);
ava=findViewById(R.id.photo);
name=findViewById(R.id.name);
name=findViewById(R.id.username);
}
@Override
@@ -408,6 +393,7 @@ public class ComposeAutocompleteViewController{
ava.setImageDrawable(null);
}
@SuppressLint("SetTextI18n")
@Override
public void onBind(WrappedEmoji item){
name.setText(":"+item.emoji.shortcode+":");

View File

@@ -1,84 +0,0 @@
package org.joinmastodon.android.ui.drawables;
import android.graphics.Canvas;
import android.graphics.ColorFilter;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.PixelFormat;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import me.grishka.appkit.utils.V;
public class ComposeAutocompleteBackgroundDrawable extends Drawable{
private Path path=new Path();
private Paint paint=new Paint(Paint.ANTI_ALIAS_FLAG);
private int fillColor, arrowOffset;
public ComposeAutocompleteBackgroundDrawable(int fillColor){
this.fillColor=fillColor;
}
@Override
public void draw(@NonNull Canvas canvas){
Rect bounds=getBounds();
canvas.save();
canvas.translate(bounds.left, bounds.top);
paint.setColor(0x80000000);
canvas.drawPath(path, paint);
canvas.translate(0, V.dp(1));
paint.setColor(fillColor);
canvas.drawPath(path, paint);
int arrowSize=V.dp(10);
canvas.drawRect(0, arrowSize, bounds.width(), bounds.height(), paint);
canvas.restore();
}
@Override
public void setAlpha(int alpha){
}
@Override
public void setColorFilter(@Nullable ColorFilter colorFilter){
}
@Override
public int getOpacity(){
return PixelFormat.TRANSLUCENT;
}
public void setArrowOffset(int offset){
arrowOffset=offset;
updatePath();
invalidateSelf();
}
@Override
protected void onBoundsChange(Rect bounds){
super.onBoundsChange(bounds);
updatePath();
}
@Override
public boolean getPadding(@NonNull Rect padding){
padding.top=V.dp(11);
return true;
}
private void updatePath(){
path.rewind();
int arrowSize=V.dp(10);
path.moveTo(0, arrowSize*2);
path.lineTo(0, arrowSize);
path.lineTo(arrowOffset-arrowSize, arrowSize);
path.lineTo(arrowOffset, 0);
path.lineTo(arrowOffset+arrowSize, arrowSize);
path.lineTo(getBounds().width(), arrowSize);
path.lineTo(getBounds().width(), arrowSize*2);
path.close();
}
}

View File

@@ -259,6 +259,10 @@ public class PhotoViewer implements ZoomPanView.Listener{
});
}
public void removeMenu(){
toolbar.getMenu().clear();
}
@Override
public void onTransitionAnimationUpdate(float translateX, float translateY, float scale){
listener.setTransitioningViewTransform(translateX, translateY, scale);

View File

@@ -0,0 +1,67 @@
package org.joinmastodon.android.ui.text;
import android.content.Context;
import android.text.Editable;
import android.text.TextWatcher;
import android.text.style.BackgroundColorSpan;
import android.text.style.ForegroundColorSpan;
import org.joinmastodon.android.R;
import org.joinmastodon.android.ui.utils.UiUtils;
public class LengthLimitHighlighter implements TextWatcher{
private final Context context;
private final int lengthLimit;
private BackgroundColorSpan overLimitBG;
private ForegroundColorSpan overLimitFG;
private boolean isOverLimit;
private OverLimitChangeListener listener;
public LengthLimitHighlighter(Context context, int lengthLimit){
this.context=context;
overLimitBG=new BackgroundColorSpan(UiUtils.getThemeColor(context, R.attr.colorM3ErrorContainer));
overLimitFG=new ForegroundColorSpan(UiUtils.getThemeColor(context, R.attr.colorM3Error));
this.lengthLimit=lengthLimit;
}
@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){
s.removeSpan(overLimitBG);
s.removeSpan(overLimitFG);
boolean newOverLimit=s.length()>lengthLimit;
if(newOverLimit){
int start=s.length()-(s.length()-lengthLimit);
int end=s.length();
s.setSpan(overLimitFG, start, end, 0);
s.setSpan(overLimitBG, start, end, 0);
}
if(newOverLimit!=isOverLimit){
isOverLimit=newOverLimit;
if(listener!=null)
listener.onOverLimitChanged(isOverLimit);
}
}
public LengthLimitHighlighter setListener(OverLimitChangeListener listener){
this.listener=listener;
return this;
}
public boolean isOverLimit(){
return isOverLimit;
}
public interface OverLimitChangeListener{
void onOverLimitChanged(boolean isOverLimit);
}
}

View File

@@ -11,7 +11,6 @@ import android.content.res.TypedArray;
import android.database.Cursor;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Typeface;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.InsetDrawable;
@@ -27,9 +26,15 @@ import android.provider.OpenableColumns;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextUtils;
import android.transition.ChangeBounds;
import android.transition.ChangeScroll;
import android.transition.Fade;
import android.transition.TransitionManager;
import android.transition.TransitionSet;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.webkit.MimeTypeMap;
import android.widget.Button;
import android.widget.PopupMenu;
@@ -86,6 +91,7 @@ import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.imageloader.ViewImageLoader;
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
import me.grishka.appkit.utils.CubicBezierInterpolator;
import me.grishka.appkit.utils.V;
import okhttp3.MediaType;
@@ -642,6 +648,10 @@ public class UiUtils{
return 0xFF000000 | (r << 16) | (g << 8) | b;
}
public static int alphaBlendThemeColors(Context context, @AttrRes int color1, @AttrRes int color2, float alpha){
return alphaBlendColors(getThemeColor(context, color1), getThemeColor(context, color2), alpha);
}
/**
* Check to see if Android platform photopicker is available on the device\
*
@@ -713,4 +723,14 @@ public class UiUtils{
else
return String.format("%d:%02d", seconds/60, seconds%60);
}
public static void beginLayoutTransition(ViewGroup sceneRoot){
TransitionManager.beginDelayedTransition(sceneRoot, new TransitionSet()
.addTransition(new Fade(Fade.IN | Fade.OUT))
.addTransition(new ChangeBounds())
.addTransition(new ChangeScroll())
.setDuration(250)
.setInterpolator(CubicBezierInterpolator.DEFAULT)
);
}
}

View File

@@ -0,0 +1,50 @@
package org.joinmastodon.android.ui.views;
import android.content.Context;
import android.util.AttributeSet;
import android.widget.Checkable;
import android.widget.LinearLayout;
public class CheckableLinearLayout extends LinearLayout implements Checkable{
private boolean checked;
private static final int[] CHECKED_STATE_SET = {
android.R.attr.state_checked
};
public CheckableLinearLayout(Context context){
this(context, null);
}
public CheckableLinearLayout(Context context, AttributeSet attrs){
this(context, attrs, 0);
}
public CheckableLinearLayout(Context context, AttributeSet attrs, int defStyle){
super(context, attrs, defStyle);
}
@Override
public void setChecked(boolean checked){
this.checked=checked;
refreshDrawableState();
}
@Override
public boolean isChecked(){
return checked;
}
@Override
public void toggle(){
setChecked(!checked);
}
@Override
protected int[] onCreateDrawableState(int extraSpace) {
final int[] drawableState = super.onCreateDrawableState(extraSpace + 1);
if (isChecked()) {
mergeDrawableStates(drawableState, CHECKED_STATE_SET);
}
return drawableState;
}
}

View File

@@ -0,0 +1,36 @@
package org.joinmastodon.android.ui.views;
import android.content.Context;
import android.util.AttributeSet;
import android.widget.ImageView;
public class FixedAspectRatioImageView extends ImageView{
private float aspectRatio=1;
public FixedAspectRatioImageView(Context context){
this(context, null);
}
public FixedAspectRatioImageView(Context context, AttributeSet attrs){
this(context, attrs, 0);
}
public FixedAspectRatioImageView(Context context, AttributeSet attrs, int defStyle){
super(context, attrs, defStyle);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec){
int width=MeasureSpec.getSize(widthMeasureSpec);
heightMeasureSpec=Math.round(width/aspectRatio) | MeasureSpec.EXACTLY;
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
public float getAspectRatio(){
return aspectRatio;
}
public void setAspectRatio(float aspectRatio){
this.aspectRatio=aspectRatio;
}
}

View File

@@ -127,7 +127,12 @@ public class FloatingHintEditTextLayout extends FrameLayout implements CustomVie
edit.getViewTreeObserver().removeOnPreDrawListener(this);
float scale=edit.getLineHeight()/(float)label.getLineHeight();
float transY=edit.getHeight()/2f-edit.getLineHeight()/2f+(edit.getTop()-label.getTop())-(label.getHeight()/2f-label.getLineHeight()/2f);
float transY;
if((edit.getGravity() & Gravity.TOP)==Gravity.TOP){
transY=edit.getPaddingTop()+(edit.getTop()-label.getTop())-(label.getHeight()/2f-label.getLineHeight()/2f);
}else{
transY=edit.getHeight()/2f-edit.getLineHeight()/2f+(edit.getTop()-label.getTop())-(label.getHeight()/2f-label.getLineHeight()/2f);
}
AnimatorSet anim=new AnimatorSet();
if(hintVisible){

View File

@@ -0,0 +1,36 @@
package org.joinmastodon.android.ui.views;
import android.content.Context;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewGroup;
import android.widget.HorizontalScrollView;
public class HorizontalScrollViewThatRespectsMatchParent extends HorizontalScrollView{
public HorizontalScrollViewThatRespectsMatchParent(Context context){
super(context);
}
public HorizontalScrollViewThatRespectsMatchParent(Context context, AttributeSet attrs){
super(context, attrs);
}
public HorizontalScrollViewThatRespectsMatchParent(Context context, AttributeSet attrs, int defStyleAttr){
super(context, attrs, defStyleAttr);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec){
if(getChildCount()==0)
return;
View child=getChildAt(0);
ViewGroup.LayoutParams lp=child.getLayoutParams();
if(lp.width==ViewGroup.LayoutParams.MATCH_PARENT){
int hms=getChildMeasureSpec(heightMeasureSpec, getPaddingTop()+getPaddingBottom(), lp.height);
child.measure(MeasureSpec.getSize(widthMeasureSpec) | MeasureSpec.EXACTLY, hms);
setMeasuredDimension(child.getMeasuredWidth(), child.getMeasuredHeight());
return;
}
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
}

View File

@@ -6,19 +6,48 @@ import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewTreeObserver;
import android.view.animation.Interpolator;
import android.widget.LinearLayout;
import org.joinmastodon.android.R;
import androidx.annotation.Nullable;
import me.grishka.appkit.utils.CubicBezierInterpolator;
import me.grishka.appkit.utils.CustomViewHelper;
import me.grishka.appkit.utils.V;
public class ReorderableLinearLayout extends LinearLayout{
public class ReorderableLinearLayout extends LinearLayout implements CustomViewHelper{
private static final String TAG="ReorderableLinearLayout";
private static final Interpolator sDragScrollInterpolator=t->t * t * t * t * t;
private static final Interpolator sDragViewScrollCapInterpolator=t->{
t -= 1.0f;
return t * t * t * t * t + 1.0f;
};
private static final long DRAG_SCROLL_ACCELERATION_LIMIT_TIME_MS = 2000;
private View draggedView;
private View bottomSibling, topSibling;
private float startY;
private float startX, startY, dX, dY, viewStartX, viewStartY;
private OnDragListener dragListener;
private boolean moveInBothDimensions;
private int edgeSize;
private View scrollableParent;
private long dragScrollStartTime;
private int cachedMaxScrollSpeed=-1;
final Runnable scrollRunnable= new Runnable() {
@Override
public void run() {
if (draggedView != null && scrollIfNecessary()) {
if (draggedView != null) { //it might be lost during scrolling
// moveIfNecessary(mSelected);
}
removeCallbacks(scrollRunnable);
postOnAnimation(this);
}
}
};
public ReorderableLinearLayout(Context context){
super(context);
@@ -30,12 +59,13 @@ public class ReorderableLinearLayout extends LinearLayout{
public ReorderableLinearLayout(Context context, @Nullable AttributeSet attrs, int defStyleAttr){
super(context, attrs, defStyleAttr);
edgeSize=dp(20);
}
public void startDragging(View child){
getParent().requestDisallowInterceptTouchEvent(true);
draggedView=child;
draggedView.animate().translationZ(V.dp(1f)).setDuration(150).setInterpolator(CubicBezierInterpolator.DEFAULT).start();
dragListener.onDragStart(draggedView);
int index=indexOfChild(child);
if(index==-1)
@@ -44,11 +74,30 @@ public class ReorderableLinearLayout extends LinearLayout{
topSibling=getChildAt(index-1);
if(index<getChildCount()-1)
bottomSibling=getChildAt(index+1);
scrollableParent=findScrollableParent(this);
viewStartX=child.getX();
viewStartY=child.getY();
}
private View findScrollableParent(View child){
if(getOrientation()==VERTICAL){
if(child.canScrollVertically(-1) || child.canScrollVertically(1))
return child;
}else{
if(child.canScrollHorizontally(-1) || child.canScrollHorizontally(1))
return child;
}
if(child.getParent() instanceof View v)
return findScrollableParent(v);
return null;
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev){
if(draggedView!=null){
startX=ev.getX();
startY=ev.getY();
return true;
}
@@ -60,34 +109,61 @@ public class ReorderableLinearLayout extends LinearLayout{
if(draggedView!=null){
if(ev.getAction()==MotionEvent.ACTION_UP || ev.getAction()==MotionEvent.ACTION_CANCEL){
endDrag();
removeCallbacks(scrollRunnable);
draggedView=null;
bottomSibling=null;
topSibling=null;
}else if(ev.getAction()==MotionEvent.ACTION_MOVE){
draggedView.setTranslationY(ev.getY()-startY);
if(topSibling!=null && draggedView.getY()<=topSibling.getY()){
moveDraggedView(-1);
}else if(bottomSibling!=null && draggedView.getY()>=bottomSibling.getY()){
moveDraggedView(1);
dX=ev.getX()-startX;
dY=ev.getY()-startY;
if(moveInBothDimensions){
draggedView.setTranslationX(dX);
draggedView.setTranslationY(dY);
}else if(getOrientation()==VERTICAL){
draggedView.setTranslationY(dY);
}else{
draggedView.setTranslationX(dX);
}
removeCallbacks(scrollRunnable);
scrollRunnable.run();
if(getOrientation()==VERTICAL){
if(topSibling!=null && draggedView.getY()<=topSibling.getY()){
moveDraggedView(-1);
}else if(bottomSibling!=null && draggedView.getY()>=bottomSibling.getY()){
moveDraggedView(1);
}
}else{
if(topSibling!=null && draggedView.getX()<=topSibling.getX()){
moveDraggedView(-1);
}else if(bottomSibling!=null && draggedView.getX()>=bottomSibling.getX()){
moveDraggedView(1);
}
}
dragListener.onDragMove(draggedView);
}
}
return super.onTouchEvent(ev);
}
private void endDrag(){
draggedView.animate().translationY(0f).translationZ(0f).setDuration(200).setInterpolator(CubicBezierInterpolator.DEFAULT).start();
dragListener.onDragEnd(draggedView);
}
private void moveDraggedView(int positionOffset){
int index=indexOfChild(draggedView);
int prevTop=draggedView.getTop();
boolean isVertical=getOrientation()==VERTICAL;
int prevOffset=isVertical ? draggedView.getTop() : draggedView.getLeft();
removeView(draggedView);
int prevIndex=index;
index+=positionOffset;
addView(draggedView, index);
final View prevSibling=positionOffset<0 ? topSibling : bottomSibling;
int prevSiblingTop=prevSibling.getTop();
int prevSiblingOffset=isVertical ? prevSibling.getTop() : prevSibling.getLeft();
if(index>0)
topSibling=getChildAt(index-1);
else
@@ -101,11 +177,20 @@ public class ReorderableLinearLayout extends LinearLayout{
@Override
public boolean onPreDraw(){
draggedView.getViewTreeObserver().removeOnPreDrawListener(this);
float offset=prevTop-draggedView.getTop();
startY-=offset;
draggedView.setTranslationY(draggedView.getTranslationY()+offset);
prevSibling.setTranslationY(prevSiblingTop-prevSibling.getTop());
prevSibling.animate().translationY(0f).setInterpolator(CubicBezierInterpolator.DEFAULT).setDuration(200).start();
float offset=prevOffset-(isVertical ? draggedView.getTop() : draggedView.getLeft());
if(isVertical){
startY-=offset;
viewStartY-=offset;
draggedView.setTranslationY(draggedView.getTranslationY()+offset);
prevSibling.setTranslationY(prevSiblingOffset-prevSibling.getTop());
prevSibling.animate().translationY(0f).setInterpolator(CubicBezierInterpolator.DEFAULT).setDuration(200).start();
}else{
startX-=offset;
viewStartX-=offset;
draggedView.setTranslationX(draggedView.getTranslationX()+offset);
prevSibling.setTranslationX(prevSiblingOffset-prevSibling.getLeft());
prevSibling.animate().translationX(0f).setInterpolator(CubicBezierInterpolator.DEFAULT).setDuration(200).start();
}
return true;
}
});
@@ -115,7 +200,105 @@ public class ReorderableLinearLayout extends LinearLayout{
this.dragListener=dragListener;
}
public boolean isMoveInBothDimensions(){
return moveInBothDimensions;
}
public void setMoveInBothDimensions(boolean moveInBothDimensions){
this.moveInBothDimensions=moveInBothDimensions;
}
boolean scrollIfNecessary(){
if(draggedView==null || scrollableParent==null){
dragScrollStartTime=Long.MIN_VALUE;
return false;
}
final long now=System.currentTimeMillis();
final long scrollDuration=dragScrollStartTime==Long.MIN_VALUE ? 0 : now-dragScrollStartTime;
int scrollX=0;
int scrollY=0;
if(getOrientation()==HORIZONTAL){
int curX=(int) (viewStartX+dX)-scrollableParent.getScrollX();
final int leftDiff=curX-getPaddingLeft();
if(dX<0 && leftDiff<0){
scrollX=leftDiff;
}else if(dX>0){
final int rightDiff=curX+draggedView.getWidth()-(scrollableParent.getWidth()-getPaddingRight());
if(rightDiff>0){
scrollX=rightDiff;
}
}
}else{
int curY=(int) (viewStartY+dY)-scrollableParent.getScrollY();
final int topDiff=curY-getPaddingTop();
if(dY<0 && topDiff<0){
scrollY=topDiff;
}else if(dY>0){
final int bottomDiff=curY+draggedView.getHeight()-(scrollableParent.getHeight()-getPaddingBottom());
if(bottomDiff>0){
scrollY=bottomDiff;
}
}
}
if(scrollX!=0){
scrollX=interpolateOutOfBoundsScroll(draggedView.getWidth(), scrollX, scrollableParent.getWidth(), scrollDuration);
}
if(scrollY!=0){
scrollY=interpolateOutOfBoundsScroll(draggedView.getHeight(), scrollY, scrollableParent.getHeight(), scrollDuration);
}
if(scrollX!=0 || scrollY!=0){
if(dragScrollStartTime==Long.MIN_VALUE){
dragScrollStartTime=now;
}
int prevX=scrollableParent.getScrollX();
int prevY=scrollableParent.getScrollY();
scrollableParent.scrollBy(scrollX, scrollY);
draggedView.setTranslationX(draggedView.getTranslationX()-(scrollableParent.getScrollX()-prevX));
draggedView.setTranslationY(draggedView.getTranslationY()-(scrollableParent.getScrollY()-prevY));
return true;
}
dragScrollStartTime=Long.MIN_VALUE;
return false;
}
public int interpolateOutOfBoundsScroll(int viewSize, int viewSizeOutOfBounds, int totalSize, long msSinceStartScroll){
final int maxScroll=getMaxDragScroll();
final int absOutOfBounds=Math.abs(viewSizeOutOfBounds);
final int direction=(int) Math.signum(viewSizeOutOfBounds);
// might be negative if other direction
float outOfBoundsRatio=Math.min(1f, 1f*absOutOfBounds/viewSize);
final int cappedScroll=(int) (direction*maxScroll*sDragViewScrollCapInterpolator.getInterpolation(outOfBoundsRatio));
final float timeRatio;
if(msSinceStartScroll>DRAG_SCROLL_ACCELERATION_LIMIT_TIME_MS){
timeRatio=1f;
}else{
timeRatio=(float) msSinceStartScroll/DRAG_SCROLL_ACCELERATION_LIMIT_TIME_MS;
}
final int value=(int) (cappedScroll*sDragScrollInterpolator.getInterpolation(timeRatio));
if(value==0){
return viewSizeOutOfBounds>0 ? 1 : -1;
}
return value;
}
private int getMaxDragScroll(){
if(cachedMaxScrollSpeed==-1){
cachedMaxScrollSpeed=getResources().getDimensionPixelSize(R.dimen.item_touch_helper_max_drag_scroll_per_frame);
}
return cachedMaxScrollSpeed;
}
public interface OnDragListener{
void onSwapItems(int oldIndex, int newIndex);
default void onDragStart(View view){
view.animate().translationZ(V.dp(3f)).setDuration(150).setInterpolator(CubicBezierInterpolator.DEFAULT).start();
}
default void onDragEnd(View view){
view.animate().translationY(0f).translationX(0f).translationZ(0f).setDuration(200).setInterpolator(CubicBezierInterpolator.DEFAULT).start();
}
default void onDragMove(View view){}
}
}