diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/ResizedImageRequestBody.java b/mastodon/src/main/java/org/joinmastodon/android/api/ResizedImageRequestBody.java index 674f5dc4b..282bf820a 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/ResizedImageRequestBody.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/ResizedImageRequestBody.java @@ -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 diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java index d7865b777..32ff80be9 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java @@ -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))); - editDirty=true; + 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))); diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/OnboardingProfileSetupFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/OnboardingProfileSetupFragment.java index 619801a8a..1465a72e8 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/OnboardingProfileSetupFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/OnboardingProfileSetupFragment.java @@ -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 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(){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/photoviewer/AvatarCropper.java b/mastodon/src/main/java/org/joinmastodon/android/ui/photoviewer/AvatarCropper.java new file mode 100644 index 000000000..67335a6fa --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/photoviewer/AvatarCropper.java @@ -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_INTdismiss(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); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/photoviewer/PhotoViewer.java b/mastodon/src/main/java/org/joinmastodon/android/ui/photoviewer/PhotoViewer.java index 5960994e8..bf04a2423 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/photoviewer/PhotoViewer.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/photoviewer/PhotoViewer.java @@ -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,42 +206,38 @@ public class PhotoViewer implements ZoomPanView.Listener{ wm=activity.getWindowManager(); - windowView=new FrameLayout(activity){ - @Override - public boolean dispatchKeyEvent(KeyEvent event){ - if(Build.VERSION.SDK_INT{ + if(Build.VERSION.SDK_INT0 ? Math.max(bottomInset+V.dp(8), V.dp(40)) : V.dp(12)); - insets=insets.replaceSystemWindowInsets(insets.getSystemWindowInsetLeft(), insets.getSystemWindowInsetTop(), insets.getSystemWindowInsetRight(), 0); - if(Build.VERSION.SDK_INT>=29){ - DisplayCutout cutout=insets.getDisplayCutout(); - Insets tappable=insets.getTappableElementInsets(); - if(cutout!=null){ - // Make controls extend beneath the cutout, and replace insets to avoid cutout insets being filled with "navigation bar color" - int leftInset=Math.max(0, cutout.getSafeInsetLeft()-tappable.left); - int rightInset=Math.max(0, cutout.getSafeInsetRight()-tappable.right); - toolbarWrap.setPadding(leftInset, 0, rightInset, 0); - bottomBar.setPadding(leftInset, bottomBar.getPaddingTop(), rightInset, bottomBar.getPaddingBottom()); - }else{ - toolbarWrap.setPadding(0, 0, 0, 0); - bottomBar.setPadding(0, bottomBar.getPaddingTop(), 0, bottomBar.getPaddingBottom()); - } - insets=insets.replaceSystemWindowInsets(tappable.left, tappable.top, tappable.right, bottomBar.getVisibility()==View.VISIBLE ? 0 : tappable.bottom); + return false; + }); + 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); + if(Build.VERSION.SDK_INT>=29){ + DisplayCutout cutout=insets.getDisplayCutout(); + Insets tappable=insets.getTappableElementInsets(); + if(cutout!=null){ + // Make controls extend beneath the cutout, and replace insets to avoid cutout insets being filled with "navigation bar color" + int leftInset=Math.max(0, cutout.getSafeInsetLeft()-tappable.left); + int rightInset=Math.max(0, cutout.getSafeInsetRight()-tappable.right); + toolbarWrap.setPadding(leftInset, 0, rightInset, 0); + bottomBar.setPadding(leftInset, bottomBar.getPaddingTop(), rightInset, bottomBar.getPaddingBottom()); + }else{ + toolbarWrap.setPadding(0, 0, 0, 0); + bottomBar.setPadding(0, bottomBar.getPaddingTop(), 0, bottomBar.getPaddingBottom()); } - uiOverlay.dispatchApplyWindowInsets(insets); - return insets.consumeSystemWindowInsets(); + insets=insets.replaceSystemWindowInsets(tappable.left, tappable.top, tappable.right, bottomBar.getVisibility()==View.VISIBLE ? 0 : tappable.bottom); } - }; + uiOverlay.dispatchApplyWindowInsets(insets); + return insets.consumeSystemWindowInsets(); + }); windowView.setBackground(background); background.setAlpha(0); pager=new ViewPager2(activity); diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/photoviewer/ZoomPanView.java b/mastodon/src/main/java/org/joinmastodon/android/ui/photoviewer/ZoomPanView.java index 81c0601b3..4b619f107 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/photoviewer/ZoomPanView.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/photoviewer/ZoomPanView.java @@ -46,6 +46,8 @@ public class ZoomPanView extends FrameLayout implements ScaleGestureDetector.OnS private float lastScaleCenterX, lastScaleCenterY; private boolean canScrollLeft, canScrollRight; private ArrayList 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()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(); diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/views/WindowRootFrameLayout.java b/mastodon/src/main/java/org/joinmastodon/android/ui/views/WindowRootFrameLayout.java new file mode 100644 index 000000000..e3c646229 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/views/WindowRootFrameLayout.java @@ -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; + } +} diff --git a/mastodon/src/main/res/layout/avatar_cropper.xml b/mastodon/src/main/res/layout/avatar_cropper.xml new file mode 100644 index 000000000..f83465d86 --- /dev/null +++ b/mastodon/src/main/res/layout/avatar_cropper.xml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/layout/fragment_onboarding_profile_setup.xml b/mastodon/src/main/res/layout/fragment_onboarding_profile_setup.xml index 2ad0cb941..846c5e908 100644 --- a/mastodon/src/main/res/layout/fragment_onboarding_profile_setup.xml +++ b/mastodon/src/main/res/layout/fragment_onboarding_profile_setup.xml @@ -39,6 +39,7 @@ android:background="?colorM3SecondaryContainer"/> Your account has been suspended. Learn more More + Move and scale + Choose \ No newline at end of file