Avatar cropping (AND-203)

closes #76
This commit is contained in:
Grishka 2024-10-30 13:39:06 +03:00
parent 1b6c299251
commit 3de494f9e9
10 changed files with 606 additions and 64 deletions

View File

@ -31,7 +31,7 @@ import okio.Source;
public class ResizedImageRequestBody extends CountingRequestBody{
private File tempFile;
private Uri uri;
private String contentType;
private MediaType contentType;
private int maxSize;
public ResizedImageRequestBody(Uri uri, int maxSize, ProgressListener progressListener) throws IOException{
@ -42,15 +42,16 @@ public class ResizedImageRequestBody extends CountingRequestBody{
opts.inJustDecodeBounds=true;
if("file".equals(uri.getScheme())){
BitmapFactory.decodeFile(uri.getPath(), opts);
contentType=UiUtils.getFileMediaType(new File(uri.getPath())).type();
contentType=UiUtils.getFileMediaType(new File(uri.getPath()));
}else{
try(InputStream in=MastodonApp.context.getContentResolver().openInputStream(uri)){
BitmapFactory.decodeStream(in, null, opts);
}
contentType=MastodonApp.context.getContentResolver().getType(uri);
String mime=MastodonApp.context.getContentResolver().getType(uri);
contentType=TextUtils.isEmpty(mime) ? null : MediaType.get(mime);
}
if(TextUtils.isEmpty(contentType))
contentType="image/jpeg";
if(contentType==null)
contentType=MediaType.get("image/jpeg");
if(needResize(opts.outWidth, opts.outHeight) || needCrop(opts.outWidth, opts.outHeight)){
Bitmap bitmap;
if(Build.VERSION.SDK_INT>=28){
@ -136,7 +137,7 @@ public class ResizedImageRequestBody extends CountingRequestBody{
bitmap.compress(Bitmap.CompressFormat.PNG, 0, out);
}else{
bitmap.compress(Bitmap.CompressFormat.JPEG, 97, out);
contentType="image/jpeg";
contentType=MediaType.get("image/jpeg");
}
}
length=tempFile.length();
@ -163,7 +164,7 @@ public class ResizedImageRequestBody extends CountingRequestBody{
@Override
public MediaType contentType(){
return MediaType.get(contentType);
return contentType;
}
@Override

View File

@ -9,6 +9,7 @@ import android.app.Fragment;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Intent;
import android.content.pm.ActivityInfo;
import android.content.res.Configuration;
import android.content.res.TypedArray;
import android.graphics.Outline;
@ -66,6 +67,7 @@ import org.joinmastodon.android.ui.OutlineProviders;
import org.joinmastodon.android.ui.SimpleViewHolder;
import org.joinmastodon.android.ui.SingleImagePhotoViewerListener;
import org.joinmastodon.android.ui.Snackbar;
import org.joinmastodon.android.ui.photoviewer.AvatarCropper;
import org.joinmastodon.android.ui.photoviewer.PhotoViewer;
import org.joinmastodon.android.ui.sheets.DecentralizationExplainerSheet;
import org.joinmastodon.android.ui.tabs.TabLayout;
@ -554,8 +556,8 @@ public class ProfileFragment extends LoaderFragment implements ScrollableToTop{
}
setTitle(account.displayName);
setSubtitle(getResources().getQuantityString(R.plurals.x_posts, (int)(account.statusesCount%1000), account.statusesCount));
ViewImageLoader.load(avatar, null, new UrlImageLoaderRequest(GlobalUserPreferences.playGifs ? account.avatar : account.avatarStatic, V.dp(100), V.dp(100)));
ViewImageLoader.load(cover, null, new UrlImageLoaderRequest(GlobalUserPreferences.playGifs ? account.header : account.headerStatic, 1000, 1000));
ViewImageLoader.loadWithoutAnimation(avatar, avatar.getDrawable(), new UrlImageLoaderRequest(GlobalUserPreferences.playGifs ? account.avatar : account.avatarStatic, V.dp(100), V.dp(100)));
ViewImageLoader.loadWithoutAnimation(cover, cover.getDrawable(), new UrlImageLoaderRequest(GlobalUserPreferences.playGifs ? account.header : account.headerStatic, 1000, 1000));
SpannableStringBuilder ssb=new SpannableStringBuilder(account.displayName);
if(AccountSessionManager.get(accountID).getLocalPreferences().customEmojiInNames)
HtmlParser.parseCustomEmoji(ssb, account.emojis);
@ -1174,9 +1176,16 @@ public class ProfileFragment extends LoaderFragment implements ScrollableToTop{
public void onActivityResult(int requestCode, int resultCode, Intent data){
if(resultCode==Activity.RESULT_OK){
if(requestCode==AVATAR_RESULT){
editNewAvatar=data.getData();
ViewImageLoader.loadWithoutAnimation(avatar, null, new UrlImageLoaderRequest(editNewAvatar, V.dp(100), V.dp(100)));
if(!isTablet){
getActivity().setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
}
int radius=V.dp(25);
new AvatarCropper(getActivity(), data.getData(), new SingleImagePhotoViewerListener(avatar, avatarBorder, new int[]{radius, radius, radius, radius}, this, ()->{}, null, null, null), (thumbnail, uri)->{
getActivity().setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED);
avatar.setImageDrawable(thumbnail);
editNewAvatar=uri;
editDirty=true;
}, ()->getActivity().setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED)).show();
}else if(requestCode==COVER_RESULT){
editNewCover=data.getData();
ViewImageLoader.loadWithoutAnimation(cover, null, new UrlImageLoaderRequest(editNewCover, V.dp(1000), V.dp(1000)));

View File

@ -2,6 +2,7 @@ package org.joinmastodon.android.fragments.onboarding;
import android.app.Activity;
import android.content.Intent;
import android.content.pm.ActivityInfo;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
@ -24,7 +25,9 @@ import org.joinmastodon.android.model.AccountField;
import org.joinmastodon.android.model.viewmodel.CheckableListItem;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.OutlineProviders;
import org.joinmastodon.android.ui.SingleImagePhotoViewerListener;
import org.joinmastodon.android.ui.adapters.GenericListItemsAdapter;
import org.joinmastodon.android.ui.photoviewer.AvatarCropper;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.viewholders.ListItemViewHolder;
import org.joinmastodon.android.ui.views.ReorderableLinearLayout;
@ -53,6 +56,7 @@ public class OnboardingProfileSetupFragment extends ToolbarFragment{
private Uri avatarUri, coverUri;
private LinearLayout scrollContent;
private CheckableListItem<Void> discoverableItem;
private View avaBorder;
private static final int AVATAR_RESULT=348;
private static final int COVER_RESULT=183;
@ -80,6 +84,7 @@ public class OnboardingProfileSetupFragment extends ToolbarFragment{
bioEdit=view.findViewById(R.id.bio);
avaImage=view.findViewById(R.id.avatar);
coverImage=view.findViewById(R.id.header);
avaBorder=view.findViewById(R.id.avatar_border);
btn=view.findViewById(R.id.btn_next);
btn.setOnClickListener(v->onButtonClick());
@ -152,20 +157,25 @@ public class OnboardingProfileSetupFragment extends ToolbarFragment{
public void onActivityResult(int requestCode, int resultCode, Intent data){
if(resultCode!=Activity.RESULT_OK)
return;
ImageView img;
Uri uri=data.getData();
int size;
if(requestCode==AVATAR_RESULT){
img=avaImage;
avatarUri=uri;
size=V.dp(100);
if(!isTablet){
getActivity().setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
}
int radius=V.dp(25);
new AvatarCropper(getActivity(), data.getData(), new SingleImagePhotoViewerListener(avaImage, avaBorder, new int[]{radius, radius, radius, radius}, this, ()->{}, null, null, null), (thumbnail, newUri)->{
getActivity().setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED);
avaImage.setImageDrawable(thumbnail);
avaImage.setForeground(null);
avatarUri=newUri;
}, ()->getActivity().setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED)).show();
}else{
img=coverImage;
coverUri=uri;
size=V.dp(1000);
ViewImageLoader.load(coverImage, null, new UrlImageLoaderRequest(uri, size, size));
coverImage.setForeground(null);
}
img.setForeground(null);
ViewImageLoader.load(img, null, new UrlImageLoaderRequest(uri, size, size));
}
private void showDiscoverabilityAlert(){

View File

@ -0,0 +1,360 @@
package org.joinmastodon.android.ui.photoviewer;
import android.app.Activity;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.ColorFilter;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.PixelFormat;
import android.graphics.PorterDuff;
import android.graphics.PorterDuffXfermode;
import android.graphics.Rect;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.Build;
import android.view.ContextThemeWrapper;
import android.view.Gravity;
import android.view.KeyEvent;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewTreeObserver;
import android.view.WindowManager;
import android.widget.FrameLayout;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.Toast;
import android.window.OnBackInvokedDispatcher;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.MastodonApp;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.MastodonAPIController;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.views.WindowRootFrameLayout;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.List;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
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 me.grishka.appkit.views.FragmentRootLinearLayout;
public class AvatarCropper implements ZoomPanView.Listener{
private Activity activity;
private Context context;
private WindowManager wm;
private WindowRootFrameLayout windowView;
private FragmentRootLinearLayout overlay;
private ZoomPanView zoomPanView;
private ImageButton closeButton;
private ImageView image;
private View confirmButton;
private Runnable onCancel;
private OnCropChosenListener cropChosenListener;
private Uri originalUri;
private PhotoViewer.Listener listener;
private Drawable background=new ColorDrawable(0xff000000);
public AvatarCropper(Activity activity, Uri imageUri, PhotoViewer.Listener photoViewerListener, OnCropChosenListener cropChosenListener, Runnable onCancel){
this.activity=activity;
this.context=new ContextThemeWrapper(activity, UiUtils.getThemeForUserPreference(context, GlobalUserPreferences.ThemePreference.DARK));
originalUri=imageUri;
wm=context.getSystemService(WindowManager.class);
this.cropChosenListener=cropChosenListener;
this.onCancel=onCancel;
this.listener=photoViewerListener;
windowView=(WindowRootFrameLayout) LayoutInflater.from(this.context).inflate(R.layout.avatar_cropper, null);
overlay=windowView.findViewById(R.id.overlay);
closeButton=windowView.findViewById(R.id.btn_back);
zoomPanView=windowView.findViewById(R.id.zoom_pan_view);
image=windowView.findViewById(R.id.image);
confirmButton=windowView.findViewById(R.id.btn_confirm);
windowView.setBackground(background);
windowView.setDispatchApplyWindowInsetsListener((v, insets)->{
int bottomInset=0;
if(Build.VERSION.SDK_INT>=27){
int inset=insets.getSystemWindowInsetBottom();
bottomInset=inset>0 ? Math.max(inset, V.dp(24)) : 0;
}
((FrameLayout.LayoutParams)confirmButton.getLayoutParams()).bottomMargin=bottomInset+V.dp(16+80);
return overlay.dispatchApplyWindowInsets(insets);
});
windowView.setDispatchKeyEventListener((v, keyCode, event)->{
if(Build.VERSION.SDK_INT<Build.VERSION_CODES.TIRAMISU && event.getKeyCode()==KeyEvent.KEYCODE_BACK){
if(event.getAction()==KeyEvent.ACTION_DOWN){
dismiss(true, onCancel);
}
return true;
}
return false;
});
closeButton.setOnClickListener(v->dismiss(true, onCancel));
overlay.setStatusBarColor(0);
overlay.setNavigationBarColor(0);
overlay.setBackground(new OverlayDrawable());
zoomPanView.setListener(this);
zoomPanView.setFill(true);
zoomPanView.setSwipeToDismissEnabled(false);
ViewImageLoader.load(new ViewImageLoader.Target(){
@Override
public void setImageDrawable(Drawable d){
if(d!=null){
image.setImageDrawable(d);
image.setLayoutParams(new FrameLayout.LayoutParams(d.getIntrinsicWidth(), d.getIntrinsicHeight(), Gravity.CENTER));
zoomPanView.updateLayout();
}
}
@Override
public View getView(){
return image;
}
}, null, new UrlImageLoaderRequest(Bitmap.Config.ARGB_8888, 0, 0, List.of(), imageUri), false);
windowView.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom)->{
if(left==oldLeft && top==oldTop && right==oldRight && bottom==oldBottom)
return;
int width=right-left;
int height=bottom-top;
int size=V.dp(192);
int hpad=(width-size)/2;
int vpad=(height-size)/2;
zoomPanView.setPadding(hpad, vpad, hpad, vpad);
zoomPanView.updateLayout();
});
confirmButton.setOnClickListener(v->confirm());
}
public void show(){
WindowManager.LayoutParams wlp=new WindowManager.LayoutParams(WindowManager.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.MATCH_PARENT);
wlp.type=WindowManager.LayoutParams.TYPE_APPLICATION;
wlp.flags=WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN | WindowManager.LayoutParams.FLAG_LAYOUT_INSET_DECOR
| WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS;
wlp.format=PixelFormat.TRANSLUCENT;
wlp.setTitle(context.getString(R.string.avatar_move_and_scale));
if(Build.VERSION.SDK_INT>=28)
wlp.layoutInDisplayCutoutMode=Build.VERSION.SDK_INT>=30 ? WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS : WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES;
windowView.setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_LAYOUT_STABLE);
wm.addView(windowView, wlp);
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.TIRAMISU){
windowView.findOnBackInvokedDispatcher().registerOnBackInvokedCallback(OnBackInvokedDispatcher.PRIORITY_DEFAULT, ()->dismiss(true, onCancel));
}
}
public void dismiss(boolean animated, Runnable onDone){
if(animated){
windowView.animate()
.alpha(0)
.setDuration(250)
.setInterpolator(CubicBezierInterpolator.DEFAULT)
.withEndAction(()->{
wm.removeView(windowView);
if(onDone!=null)
onDone.run();
})
.start();
}else{
wm.removeView(windowView);
if(onDone!=null)
onDone.run();
}
}
@Override
public void onTransitionAnimationUpdate(float translateX, float translateY, float scale){
listener.setTransitioningViewTransform(translateX, translateY, scale);
}
@Override
public void onTransitionAnimationFinished(){
listener.endPhotoViewTransition();
}
@Override
public void onSetBackgroundAlpha(float alpha){
background.setAlpha(Math.round(255*alpha));
overlay.setAlpha(alpha);
confirmButton.setAlpha(alpha);
}
@Override
public void onStartSwipeToDismiss(){
}
@Override
public void onStartSwipeToDismissTransition(float velocityY){
}
@Override
public void onSwipeToDismissCanceled(){
}
@Override
public void onDismissed(){
listener.setPhotoViewVisibility(0, true);
wm.removeView(windowView);
listener.photoViewerDismissed();
}
@Override
public void onSingleTap(){
}
private void confirm(){
// stop receiving input events to allow the user to interact with the underlying UI while the animation is still running
WindowManager.LayoutParams wlp=(WindowManager.LayoutParams) windowView.getLayoutParams();
wlp.flags|=WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE | WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE;
windowView.setSystemUiVisibility(windowView.getSystemUiVisibility() | (activity.getWindow().getDecorView().getSystemUiVisibility() & (View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR | View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR)));
wm.updateViewLayout(windowView, wlp);
Drawable drawable=image.getDrawable();
zoomPanView.endAllAnimations();
Rect rect=new Rect();
image.getHitRect(rect);
float scale=image.getScaleX();
int x=Math.round((zoomPanView.getPaddingLeft()-rect.left)/scale);
int y=Math.round((zoomPanView.getPaddingTop()-rect.top)/scale);
int size=Math.round(V.dp(192)/scale);
if(x==0 && y==0 && drawable.getIntrinsicWidth()==drawable.getIntrinsicHeight() && size==drawable.getIntrinsicWidth()){
dismissWithTransition();
cropChosenListener.onCropChosen(drawable, originalUri);
return;
}
Bitmap croppedBitmap=Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888);
drawable.setBounds(0, 0, drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight());
Canvas c=new Canvas(croppedBitmap);
c.translate(-x, -y);
drawable.draw(c);
MastodonAPIController.runInBackground(()->{
String mimetype;
if("file".equals(originalUri.getScheme())){
mimetype=UiUtils.getFileMediaType(new File(originalUri.getPath())).type();
}else{
mimetype=activity.getContentResolver().getType(originalUri);
}
if(mimetype==null)
mimetype="image/jpeg";
Bitmap.CompressFormat format=switch(mimetype){
case "image/png", "image/gif" -> Bitmap.CompressFormat.PNG;
default -> Bitmap.CompressFormat.JPEG;
};
File outputFile=new File(activity.getCacheDir(), "avatar_upload."+(format==Bitmap.CompressFormat.PNG ? "png" : "jpg"));
try(FileOutputStream out=new FileOutputStream(outputFile)){
croppedBitmap.compress(format, 97, out);
}catch(IOException e){
activity.runOnUiThread(()->{
Toast.makeText(activity, R.string.error_saving_file, Toast.LENGTH_SHORT).show();
dismiss(true, onCancel);
});
return;
}
outputFile.deleteOnExit();
activity.runOnUiThread(()->{
image.setImageBitmap(croppedBitmap);
image.getLayoutParams().width=image.getLayoutParams().height=size;
zoomPanView.updateLayout();
cropChosenListener.onCropChosen(new BitmapDrawable(croppedBitmap), Uri.fromFile(outputFile));
dismissWithTransition();
});
});
}
private void dismissWithTransition(){
zoomPanView.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener(){
@Override
public boolean onPreDraw(){
zoomPanView.getViewTreeObserver().removeOnPreDrawListener(this);
listener.setPhotoViewVisibility(0, true);
int[] radius=new int[4];
Rect rect=new Rect();
if(listener.startPhotoViewTransition(0, rect, radius)){
zoomPanView.animateOut(rect, radius, 0);
}else{
windowView.animate()
.alpha(0)
.setDuration(300)
.setInterpolator(CubicBezierInterpolator.DEFAULT)
.withEndAction(AvatarCropper.this::onDismissed)
.start();
}
return true;
}
});
}
private static class OverlayDrawable extends Drawable{
private Path path=new Path(), tmpPath=new Path();
private Paint overlayPaint=new Paint(Paint.ANTI_ALIAS_FLAG), strokePaint=new Paint(Paint.ANTI_ALIAS_FLAG);
public OverlayDrawable(){
overlayPaint.setColor(0xb3000000);
strokePaint.setColor(0x4dffffff);
strokePaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.ADD));
strokePaint.setStyle(Paint.Style.STROKE);
strokePaint.setStrokeWidth(V.dp(1));
}
@Override
public void draw(@NonNull Canvas canvas){
canvas.drawPath(path, overlayPaint);
Rect bounds=getBounds();
float size=V.dp(192)-strokePaint.getStrokeWidth();
float x=bounds.centerX()-size/2;
float y=bounds.centerY()-size/2;
float radius=V.dp(40)-strokePaint.getStrokeWidth()/2f;
canvas.drawRoundRect(x, y, x+size, y+size, radius, radius, strokePaint);
}
@Override
public void setAlpha(int alpha){
}
@Override
public void setColorFilter(@Nullable ColorFilter colorFilter){
}
@Override
public int getOpacity(){
return PixelFormat.TRANSLUCENT;
}
@Override
protected void onBoundsChange(@NonNull Rect bounds){
path.rewind();
path.addRect(bounds.left, bounds.top, bounds.right, bounds.bottom, Path.Direction.CW);
tmpPath.rewind();
int size=V.dp(192);
int x=bounds.centerX()-size/2;
int y=bounds.centerY()-size/2;
tmpPath.addRoundRect(x, y, x+size, y+size, V.dp(40), V.dp(40), Path.Direction.CW);
path.op(tmpPath, Path.Op.DIFFERENCE);
}
}
public interface OnCropChosenListener{
void onCropChosen(Drawable thumbnail, Uri uri);
}
}

View File

@ -77,6 +77,7 @@ import org.joinmastodon.android.ui.Snackbar;
import org.joinmastodon.android.ui.drawables.VideoPlayerSeekBarThumbDrawable;
import org.joinmastodon.android.ui.utils.BlurHashDecoder;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.views.WindowRootFrameLayout;
import org.parceler.Parcels;
import java.io.File;
@ -121,7 +122,7 @@ public class PhotoViewer implements ZoomPanView.Listener{
private String accountID;
private BaseStatusListFragment<?> parentFragment;
private FrameLayout windowView;
private WindowRootFrameLayout windowView;
private FragmentRootLinearLayout uiOverlay;
private ViewPager2 pager;
private ColorDrawable background=new ColorDrawable(0xff000000);
@ -205,9 +206,8 @@ public class PhotoViewer implements ZoomPanView.Listener{
wm=activity.getWindowManager();
windowView=new FrameLayout(activity){
@Override
public boolean dispatchKeyEvent(KeyEvent event){
windowView=new WindowRootFrameLayout(activity);
windowView.setDispatchKeyEventListener((v, keyCode, event)->{
if(Build.VERSION.SDK_INT<Build.VERSION_CODES.TIRAMISU && event.getKeyCode()==KeyEvent.KEYCODE_BACK){
if(event.getAction()==KeyEvent.ACTION_DOWN){
onStartSwipeToDismissTransition(0f);
@ -215,10 +215,8 @@ public class PhotoViewer implements ZoomPanView.Listener{
return true;
}
return false;
}
@Override
public WindowInsets dispatchApplyWindowInsets(WindowInsets insets){
});
windowView.setDispatchApplyWindowInsetsListener((v, insets)->{
int bottomInset=insets.getSystemWindowInsetBottom();
bottomBar.setPadding(bottomBar.getPaddingLeft(), bottomBar.getPaddingTop(), bottomBar.getPaddingRight(), bottomInset>0 ? Math.max(bottomInset+V.dp(8), V.dp(40)) : V.dp(12));
insets=insets.replaceSystemWindowInsets(insets.getSystemWindowInsetLeft(), insets.getSystemWindowInsetTop(), insets.getSystemWindowInsetRight(), 0);
@ -239,8 +237,7 @@ public class PhotoViewer implements ZoomPanView.Listener{
}
uiOverlay.dispatchApplyWindowInsets(insets);
return insets.consumeSystemWindowInsets();
}
};
});
windowView.setBackground(background);
background.setAlpha(0);
pager=new ViewPager2(activity);

View File

@ -46,6 +46,8 @@ public class ZoomPanView extends FrameLayout implements ScaleGestureDetector.OnS
private float lastScaleCenterX, lastScaleCenterY;
private boolean canScrollLeft, canScrollRight;
private ArrayList<SpringAnimation> runningTransformAnimations=new ArrayList<>(), runningTransitionAnimations=new ArrayList<>();
private boolean fill; // whether the image should fill the viewport at min scale
private boolean swipeToDismissEnabled=true;
private RectF tmpRect=new RectF(), tmpRect2=new RectF();
// the initial/final crop rect for open/close transitions, in child coordinates
@ -116,14 +118,19 @@ public class ZoomPanView extends FrameLayout implements ScaleGestureDetector.OnS
if(child==null)
return;
int width=right-left;
int height=bottom-top;
int width=right-left-getPaddingLeft()-getPaddingRight();
int height=bottom-top-getPaddingTop()-getPaddingBottom();
if(width==0 || height==0 || child.getWidth()==0 || child.getWidth()==0){
matrix.reset();
return;
}
float scale=Math.min(width/(float)child.getWidth(), height/(float)child.getHeight());
float scale;
if(fill){
scale=Math.max(width/(float)child.getWidth(), height/(float)child.getHeight());
}else{
scale=Math.min(width/(float)child.getWidth(), height/(float)child.getHeight());
}
minScale=scale;
maxScale=Math.max(3f, height/(float)child.getHeight());
matrix.setScale(scale, scale);
@ -323,14 +330,14 @@ public class ZoomPanView extends FrameLayout implements ScaleGestureDetector.OnS
private void updateLimits(float targetScale){
float scaledWidth=child.getWidth()*targetScale;
float scaledHeight=child.getHeight()*targetScale;
if(scaledWidth>getWidth()){
minTransX=(getWidth()-Math.round(scaledWidth))/2f;
if(scaledWidth>getInsetWidth()){
minTransX=(getInsetWidth()-Math.round(scaledWidth))/2f;
maxTransX=-minTransX;
}else{
minTransX=maxTransX=0f;
}
if(scaledHeight>getHeight()){
minTransY=(getHeight()-Math.round(scaledHeight))/2f;
if(scaledHeight>getInsetHeight()){
minTransY=(getInsetHeight()-Math.round(scaledHeight))/2f;
maxTransY=-minTransY;
}else{
minTransY=maxTransY=0f;
@ -468,10 +475,10 @@ public class ZoomPanView extends FrameLayout implements ScaleGestureDetector.OnS
@Override
public boolean onScale(ScaleGestureDetector detector){
float factor=detector.getScaleFactor();
matrix.postScale(factor, factor, detector.getFocusX()-getWidth()/2f, detector.getFocusY()-getHeight()/2f);
matrix.postScale(factor, factor, detector.getFocusX()-getInsetWidth()/2f-getPaddingLeft(), detector.getFocusY()-getInsetHeight()/2f-getPaddingTop());
updateViewTransform(false);
lastScaleCenterX=detector.getFocusX()-getWidth()/2f;
lastScaleCenterY=detector.getFocusY()-getHeight()/2f;
lastScaleCenterX=detector.getFocusX()-getInsetWidth()/2f-getPaddingLeft();
lastScaleCenterY=detector.getFocusY()-getInsetHeight()/2f-getPaddingTop();
return true;
}
@ -510,7 +517,7 @@ public class ZoomPanView extends FrameLayout implements ScaleGestureDetector.OnS
return false;
if(child.getScaleX()<maxScale){
float scale=maxScale/child.getScaleX();
matrix.postScale(scale, scale, e.getX()-getWidth()/2f, e.getY()-getHeight()/2f);
matrix.postScale(scale, scale, e.getX()-getInsetWidth()/2f-getPaddingLeft(), e.getY()-getInsetHeight()/2f-getPaddingTop());
matrix.getValues(matrixValues);
transX=matrixValues[Matrix.MTRANS_X];
transY=matrixValues[Matrix.MTRANS_Y];
@ -554,7 +561,7 @@ public class ZoomPanView extends FrameLayout implements ScaleGestureDetector.OnS
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY){
if(minTransY==maxTransY && minTransY==0f){
if(minTransX==maxTransX && minTransX==0f){
if(Math.abs(totalScrollY)>Math.abs(totalScrollX)){
if(Math.abs(totalScrollY)>Math.abs(totalScrollX) && swipeToDismissEnabled){
if(!swipingToDismiss){
swipingToDismiss=true;
matrix.postTranslate(-totalScrollX, 0);
@ -630,6 +637,38 @@ public class ZoomPanView extends FrameLayout implements ScaleGestureDetector.OnS
}
}
public int getInsetWidth(){
return getWidth()-getPaddingLeft()-getPaddingRight();
}
public int getInsetHeight(){
return getHeight()-getPaddingTop()-getPaddingBottom();
}
public void setFill(boolean fill){
this.fill=fill;
}
public void endAllAnimations(){
if(!runningTransformAnimations.isEmpty()){
endTransformAnimations();
}else{
springBack();
endTransformAnimations();
}
updateViewTransform(false);
}
public void setSwipeToDismissEnabled(boolean swipeToDismissEnabled){
this.swipeToDismissEnabled=swipeToDismissEnabled;
}
private void endTransformAnimations(){
for(SpringAnimation anim:new ArrayList<>(runningTransformAnimations)){
anim.skipToEnd();
}
}
public interface Listener{
void onTransitionAnimationUpdate(float translateX, float translateY, float scale);
void onTransitionAnimationFinished();

View File

@ -0,0 +1,44 @@
package org.joinmastodon.android.ui.views;
import android.content.Context;
import android.util.AttributeSet;
import android.view.KeyEvent;
import android.view.WindowInsets;
import android.widget.FrameLayout;
public class WindowRootFrameLayout extends FrameLayout{
private OnKeyListener dispatchKeyEventListener;
private OnApplyWindowInsetsListener dispatchApplyWindowInsetsListener;
public WindowRootFrameLayout(Context context){
this(context, null);
}
public WindowRootFrameLayout(Context context, AttributeSet attrs){
this(context, attrs, 0);
}
public WindowRootFrameLayout(Context context, AttributeSet attrs, int defStyle){
super(context, attrs, defStyle);
}
@Override
public boolean dispatchKeyEvent(KeyEvent event){
return (dispatchKeyEventListener!=null && dispatchKeyEventListener.onKey(this, event.getKeyCode(), event)) || super.dispatchKeyEvent(event);
}
public void setDispatchKeyEventListener(OnKeyListener dispatchKeyEventListener){
this.dispatchKeyEventListener=dispatchKeyEventListener;
}
@Override
public WindowInsets dispatchApplyWindowInsets(WindowInsets insets){
if(dispatchApplyWindowInsetsListener!=null)
return dispatchApplyWindowInsetsListener.onApplyWindowInsets(this, insets);
return super.dispatchApplyWindowInsets(insets);
}
public void setDispatchApplyWindowInsetsListener(OnApplyWindowInsetsListener dispatchApplyWindowInsetsListener){
this.dispatchApplyWindowInsetsListener=dispatchApplyWindowInsetsListener;
}
}

View File

@ -0,0 +1,79 @@
<?xml version="1.0" encoding="utf-8"?>
<org.joinmastodon.android.ui.views.WindowRootFrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<org.joinmastodon.android.ui.photoviewer.ZoomPanView
android:id="@+id/zoom_pan_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false">
<ImageView
android:id="@+id/image"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:importantForAccessibility="no"/>
</org.joinmastodon.android.ui.photoviewer.ZoomPanView>
<me.grishka.appkit.views.FragmentRootLinearLayout
android:id="@+id/overlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="?android:actionBarSize"
android:layout_gravity="top"
android:orientation="horizontal"
android:paddingEnd="56dp">
<ImageButton
android:id="@+id/btn_back"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginHorizontal="4dp"
android:layout_gravity="center_vertical"
android:background="?android:actionBarItemBackground"
android:tint="?colorM3OnSurfaceVariant"
android:src="@drawable/ic_baseline_close_24"
android:contentDescription="@string/back"/>
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:singleLine="true"
android:textSize="22dp"
android:textColor="?colorM3OnSurface"
android:text="@string/avatar_move_and_scale"/>
</LinearLayout>
</me.grishka.appkit.views.FragmentRootLinearLayout>
<FrameLayout
android:id="@+id/btn_confirm"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:layout_gravity="bottom|center_horizontal"
android:minWidth="152dp"
android:paddingStart="16dp"
style="@style/Widget.Mastodon.M3.Button.Filled">
<TextView
android:layout_width="wrap_content"
android:layout_height="40dp"
android:layout_gravity="center_horizontal"
android:drawableStart="@drawable/ic_check_24px"
style="@style/Widget.Mastodon.M3.Button.Filled"
android:background="@null"
android:padding="0dp"
android:drawablePadding="7dp"
android:drawableTint="@color/button_text_m3_filled"
android:clickable="false"
android:focusable="false"
android:text="@string/confirm_avatar_crop"/>
</FrameLayout>
</org.joinmastodon.android.ui.views.WindowRootFrameLayout>

View File

@ -39,6 +39,7 @@
android:background="?colorM3SecondaryContainer"/>
<FrameLayout
android:id="@+id/avatar_border"
android:layout_width="104dp"
android:layout_height="104dp"
android:layout_gravity="center_horizontal"

View File

@ -811,4 +811,6 @@
<string name="moderation_warning_action_suspend">Your account has been suspended.</string>
<string name="moderation_warning_learn_more">Learn more</string>
<string name="text_show_more">More</string>
<string name="avatar_move_and_scale">Move and scale</string>
<string name="confirm_avatar_crop">Choose</string>
</resources>