diff --git a/mastodon/build.gradle b/mastodon/build.gradle index 232c0561..e1728071 100644 --- a/mastodon/build.gradle +++ b/mastodon/build.gradle @@ -32,6 +32,8 @@ dependencies { implementation 'me.grishka.litex:recyclerview:1.2.1' implementation 'me.grishka.litex:swiperefreshlayout:1.1.0' implementation 'me.grishka.litex:browser:1.4.0' + implementation 'me.grishka.litex:dynamicanimation:1.1.0-alpha03' + implementation 'me.grishka.litex:viewpager2:1.0.0' implementation 'me.grishka.appkit:appkit:1.2' implementation 'com.google.code.gson:gson:2.8.9' implementation 'org.jsoup:jsoup:1.14.3' diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/StatusListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/StatusListFragment.java index 087aad61..b16adbf3 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/StatusListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/StatusListFragment.java @@ -1,10 +1,19 @@ package org.joinmastodon.android.fragments; import android.app.Activity; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.util.Log; +import android.view.View; import android.view.ViewGroup; +import android.widget.ImageView; import org.joinmastodon.android.model.Status; +import org.joinmastodon.android.ui.displayitems.PhotoStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.StatusDisplayItem; +import org.joinmastodon.android.ui.photoviewer.PhotoViewer; +import org.joinmastodon.android.ui.photoviewer.PhotoViewerHost; import java.util.ArrayList; import java.util.List; @@ -18,10 +27,11 @@ import me.grishka.appkit.imageloader.requests.ImageLoaderRequest; import me.grishka.appkit.utils.BindableViewHolder; import me.grishka.appkit.views.UsableRecyclerView; -public abstract class StatusListFragment extends BaseRecyclerFragment{ +public abstract class StatusListFragment extends BaseRecyclerFragment implements PhotoViewerHost{ protected ArrayList displayItems=new ArrayList<>(); protected DisplayItemsAdapter adapter; protected String accountID; + protected PhotoViewer currentPhotoViewer; public StatusListFragment(){ super(20); @@ -84,6 +94,104 @@ public abstract class StatusListFragment extends BaseRecyclerFragment{ imgLoader.activate(); } + @Override + public void openPhotoViewer(Status _status, int attachmentIndex){ + final Status status=_status.reblog!=null ? _status.reblog : _status; + currentPhotoViewer=new PhotoViewer(getActivity(), status.mediaAttachments, attachmentIndex, new PhotoViewer.Listener(){ + private PhotoStatusDisplayItem.Holder transitioningHolder; + + @Override + public void setPhotoViewVisibility(int index, boolean visible){ + PhotoStatusDisplayItem.Holder holder=findPhotoViewHolder(index); + if(holder!=null) + holder.photo.setAlpha(visible ? 1f : 0f); + } + + @Override + public boolean startPhotoViewTransition(int index, @NonNull Rect outRect, @NonNull int[] outCornerRadius){ + PhotoStatusDisplayItem.Holder holder=findPhotoViewHolder(index); + if(holder!=null){ + transitioningHolder=holder; + View view=transitioningHolder.photo; + int[] pos={0, 0}; + view.getLocationOnScreen(pos); + outRect.set(pos[0], pos[1], pos[0]+view.getWidth(), pos[1]+view.getHeight()); + list.setClipChildren(false); + transitioningHolder.itemView.setElevation(1f); + return true; + } + return false; + } + + @Override + public void setTransitioningViewTransform(float translateX, float translateY, float scale){ + View view=transitioningHolder.photo; + view.setTranslationX(translateX); + view.setTranslationY(translateY); + view.setScaleX(scale); + view.setScaleY(scale); + } + + @Override + public void endPhotoViewTransition(){ + View view=transitioningHolder.photo; + view.setTranslationX(0f); + view.setTranslationY(0f); + view.setScaleX(1f); + view.setScaleY(1f); + transitioningHolder.itemView.setElevation(0f); + list.setClipChildren(true); + transitioningHolder=null; + } + + @Override + public Drawable getPhotoViewCurrentDrawable(int index){ + PhotoStatusDisplayItem.Holder holder=findPhotoViewHolder(index); + if(holder!=null) + return holder.photo.getDrawable(); + return null; + } + + @Override + public void photoViewerDismissed(){ + currentPhotoViewer=null; + } + + private PhotoStatusDisplayItem.Holder findPhotoViewHolder(int index){ + int offset=0; + for(StatusDisplayItem item:displayItems){ + if(item.status==_status){ + if(item instanceof PhotoStatusDisplayItem){ + RecyclerView.ViewHolder holder=list.findViewHolderForAdapterPosition(getMainAdapterOffset()+offset+index); + if(holder instanceof PhotoStatusDisplayItem.Holder){ + return (PhotoStatusDisplayItem.Holder) holder; + } + return null; + } + } + offset++; + } + return null; + } + }); + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState){ + super.onViewCreated(view, savedInstanceState); + list.addOnScrollListener(new RecyclerView.OnScrollListener(){ + @Override + public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy){ + if(currentPhotoViewer!=null) + currentPhotoViewer.offsetView(-dx, -dy); + } + }); + } + + protected int getMainAdapterOffset(){ + return 0; + } + protected class DisplayItemsAdapter extends UsableRecyclerView.Adapter> implements ImageLoaderRecyclerAdapter{ public DisplayItemsAdapter(){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/PhotoStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/PhotoStatusDisplayItem.java index c77128e4..61211742 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/PhotoStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/PhotoStatusDisplayItem.java @@ -1,14 +1,16 @@ package org.joinmastodon.android.ui.displayitems; import android.app.Activity; -import android.graphics.Bitmap; +import android.app.Fragment; import android.graphics.drawable.Drawable; +import android.view.View; import android.view.ViewGroup; import android.widget.ImageView; import org.joinmastodon.android.R; import org.joinmastodon.android.model.Attachment; import org.joinmastodon.android.model.Status; +import org.joinmastodon.android.ui.photoviewer.PhotoViewerHost; import me.grishka.appkit.imageloader.ImageLoaderViewHolder; import me.grishka.appkit.imageloader.requests.ImageLoaderRequest; @@ -18,10 +20,12 @@ import me.grishka.appkit.utils.BindableViewHolder; public class PhotoStatusDisplayItem extends StatusDisplayItem{ private Attachment attachment; private ImageLoaderRequest request; - public PhotoStatusDisplayItem(Status status, Attachment photo){ + private Fragment parentFragment; + public PhotoStatusDisplayItem(Status status, Attachment photo, Fragment parentFragment){ super(status); this.attachment=photo; request=new UrlImageLoaderRequest(photo.url, 1000, 1000); + this.parentFragment=parentFragment; } @Override @@ -40,10 +44,11 @@ public class PhotoStatusDisplayItem extends StatusDisplayItem{ } public static class Holder extends BindableViewHolder implements ImageLoaderViewHolder{ - private final ImageView photo; + public final ImageView photo; public Holder(Activity activity, ViewGroup parent){ super(activity, R.layout.display_item_photo, parent); photo=findViewById(R.id.photo); + photo.setOnClickListener(this::onViewClick); } @Override @@ -60,5 +65,12 @@ public class PhotoStatusDisplayItem extends StatusDisplayItem{ public void clearImage(int index){ photo.setImageDrawable(item.attachment.blurhashPlaceholder); } + + private void onViewClick(View v){ + if(item.parentFragment instanceof PhotoViewerHost){ + Status contentStatus=item.status.reblog!=null ? item.status.reblog : item.status; + ((PhotoViewerHost) item.parentFragment).openPhotoViewer(item.status, contentStatus.mediaAttachments.indexOf(item.attachment)); + } + } } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/StatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/StatusDisplayItem.java index 652bae63..091fbda5 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/StatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/StatusDisplayItem.java @@ -53,7 +53,7 @@ public abstract class StatusDisplayItem{ items.add(new TextStatusDisplayItem(status, HtmlParser.parse(statusForContent.content, statusForContent.emojis), fragment)); for(Attachment attachment:statusForContent.mediaAttachments){ if(attachment.type==Attachment.Type.IMAGE){ - items.add(new PhotoStatusDisplayItem(status, attachment)); + items.add(new PhotoStatusDisplayItem(status, attachment, fragment)); } } return items; 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 new file mode 100644 index 00000000..35c258f9 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/photoviewer/PhotoViewer.java @@ -0,0 +1,247 @@ +package org.joinmastodon.android.ui.photoviewer; + +import android.app.Activity; +import android.graphics.PixelFormat; +import android.graphics.Rect; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.Drawable; +import android.view.Gravity; +import android.view.KeyEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewTreeObserver; +import android.view.WindowManager; +import android.widget.FrameLayout; +import android.widget.ImageView; + +import org.joinmastodon.android.model.Attachment; + +import java.util.List; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; +import androidx.viewpager2.widget.ViewPager2; +import me.grishka.appkit.imageloader.ViewImageLoader; +import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest; +import me.grishka.appkit.utils.BindableViewHolder; +import me.grishka.appkit.utils.CubicBezierInterpolator; + +public class PhotoViewer implements ZoomPanView.Listener{ + private Activity activity; + private List attachments; + private int currentIndex; + private WindowManager wm; + private Listener listener; + + private FrameLayout windowView; + private ViewPager2 pager; + private ColorDrawable background=new ColorDrawable(0xff000000); + + public PhotoViewer(Activity activity, List attachments, int index, Listener listener){ + this.activity=activity; + this.attachments=attachments; + currentIndex=index; + this.listener=listener; + + wm=activity.getWindowManager(); + + windowView=new FrameLayout(activity){ + @Override + public boolean dispatchKeyEvent(KeyEvent event){ + if(event.getAction()==KeyEvent.ACTION_DOWN && event.getKeyCode()==KeyEvent.KEYCODE_BACK){ + onStartSwipeToDismissTransition(0f); + } + return true; + } + }; + windowView.setBackground(background); + background.setAlpha(0); + pager=new ViewPager2(activity); + pager.setAdapter(new PhotoViewAdapter()); + pager.setCurrentItem(index, false); + windowView.addView(pager); + pager.setMotionEventSplittingEnabled(false); + + 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.RGBA_8888; + wm.addView(windowView, wlp); + + windowView.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener(){ + @Override + public boolean onPreDraw(){ + windowView.getViewTreeObserver().removeOnPreDrawListener(this); + + Rect rect=new Rect(); + int[] radius=new int[4]; + if(listener.startPhotoViewTransition(index, rect, radius)){ + RecyclerView rv=(RecyclerView) pager.getChildAt(0); + PhotoViewHolder holder=(PhotoViewHolder) rv.findViewHolderForAdapterPosition(index); + holder.zoomPanView.animateIn(rect, radius); + } + + return true; + } + }); + } + + @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(alpha*255f)); + } + + @Override + public void onStartSwipeToDismiss(){ + listener.setPhotoViewVisibility(pager.getCurrentItem(), false); + } + + @Override + public void onStartSwipeToDismissTransition(float velocityY){ + // 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; + wm.updateViewLayout(windowView, wlp); + + int index=pager.getCurrentItem(); + listener.setPhotoViewVisibility(index, true); + Rect rect=new Rect(); + int[] radius=new int[4]; + if(listener.startPhotoViewTransition(index, rect, radius)){ + RecyclerView rv=(RecyclerView) pager.getChildAt(0); + PhotoViewHolder holder=(PhotoViewHolder) rv.findViewHolderForAdapterPosition(index); + holder.zoomPanView.animateOut(rect, radius, velocityY); + }else{ + windowView.animate() + .alpha(0) + .setDuration(300) + .setInterpolator(CubicBezierInterpolator.DEFAULT) + .withEndAction(()->wm.removeView(windowView)) + .start(); + } + } + + @Override + public void onSwipeToDismissCanceled(){ + listener.setPhotoViewVisibility(pager.getCurrentItem(), true); + } + + @Override + public void onDismissed(){ + listener.setPhotoViewVisibility(pager.getCurrentItem(), true); + wm.removeView(windowView); + listener.photoViewerDismissed(); + } + + /** + * To be called when the list containing photo views is scrolled + * @param x + * @param y + */ + public void offsetView(float x, float y){ + pager.setTranslationX(pager.getTranslationX()+x); + pager.setTranslationY(pager.getTranslationY()+y); + } + + public interface Listener{ + void setPhotoViewVisibility(int index, boolean visible); + + /** + * Find a view for transition, save a reference to it until {@link #endPhotoViewTransition()} is called, + * and set up the view hierarchy for transition (the photo view may need to be drawn outside of the bounds of its parent). + * @param index the index of the photo/page + * @param outRect output: the rect of the photo view in screen coordinates + * @param outCornerRadius output: corner radiuses of the view [top-left, top-right, bottom-right, bottom-left] + * @return true if the view was found and outRect and outCornerRadius are valid + */ + boolean startPhotoViewTransition(int index, @NonNull Rect outRect, @NonNull int[] outCornerRadius); + + /** + * Update the transformation parameters of the transitioning photo view. + * Only called if a previous call to {@link #startPhotoViewTransition(int, Rect, int[])} returned true. + * @param translateX X translation + * @param translateY Y translation + * @param scale X and Y scale + */ + void setTransitioningViewTransform(float translateX, float translateY, float scale); + + /** + * End the transition, returning all transformations to their initial state. + */ + void endPhotoViewTransition(); + + /** + * Get the current drawable that a photo view displays. + * @param index the index of the photo + * @return the drawable, or null if the view doesn't exist + */ + @Nullable + Drawable getPhotoViewCurrentDrawable(int index); + + void photoViewerDismissed(); + } + + private class PhotoViewAdapter extends RecyclerView.Adapter{ + + @NonNull + @Override + public PhotoViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){ + return new PhotoViewHolder(); + } + + @Override + public void onBindViewHolder(@NonNull PhotoViewHolder holder, int position){ + holder.bind(attachments.get(position)); + } + + @Override + public int getItemCount(){ + return attachments.size(); + } + } + + private class PhotoViewHolder extends BindableViewHolder implements ViewImageLoader.Target{ + public ImageView imageView; + public ZoomPanView zoomPanView; + + public PhotoViewHolder(){ + super(new ZoomPanView(activity)); + zoomPanView=(ZoomPanView) itemView; + zoomPanView.setListener(PhotoViewer.this); + itemView.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + imageView=new ImageView(activity); + ((FrameLayout)itemView).addView(imageView, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.CENTER)); + } + + @Override + public void onBind(Attachment item){ + FrameLayout.LayoutParams params=(FrameLayout.LayoutParams) imageView.getLayoutParams(); + params.width=item.getWidth(); + params.height=item.getHeight(); + zoomPanView.setScrollDirections(getAbsoluteAdapterPosition()>0, getAbsoluteAdapterPosition() runningTransformAnimations=new ArrayList<>(), runningTransitionAnimations=new ArrayList<>(); + + private RectF tmpRect=new RectF(), tmpRect2=new RectF(); + // the initial/final crop rect for open/close transitions, in child coordinates + private RectF transitionCropRect=new RectF(); + private float cropAnimationValue, rawCropAndFadeValue; + private float lastFlingVelocityY; + private float backgroundAlphaForTransition=1f; + + private static final String TAG="ZoomPanView"; + + private Runnable scrollerUpdater=this::doScrollerAnimation; + private Listener listener; + private static final FloatPropertyCompat CROP_AND_FADE=new FloatPropertyCompat<>("cropAndFade"){ + @Override + public float getValue(ZoomPanView object){ + return object.rawCropAndFadeValue; + } + + @Override + public void setValue(ZoomPanView object, float value){ + object.rawCropAndFadeValue=value; + if(value>0.1f) + object.child.setAlpha(Math.min((value-0.1f)/0.4f, 1f)); + else + object.child.setAlpha(0f); + + if(value>0.3f) + object.setCropAnimationValue(Math.min(1f, (value-0.3f)/0.7f)); + else + object.setCropAnimationValue(0f); + + if(value>0.5f) + object.listener.onSetBackgroundAlpha(Math.min(1f, (value-0.5f)/0.5f*object.backgroundAlphaForTransition)); + else + object.listener.onSetBackgroundAlpha(0f); + + object.invalidate(); + } + }; + + public ZoomPanView(Context context){ + this(context, null); + } + + public ZoomPanView(Context context, AttributeSet attrs){ + this(context, attrs, 0); + } + + public ZoomPanView(Context context, AttributeSet attrs, int defStyle){ + super(context, attrs, defStyle); + gestureDetector=new GestureDetector(context, this); + gestureDetector.setIsLongpressEnabled(false); + gestureDetector.setOnDoubleTapListener(this); + scaleDetector=new ScaleGestureDetector(context, this); + scroller=new OverScroller(context); + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom){ + super.onLayout(changed, left, top, right, bottom); + child=getChildAt(0); + if(child==null) + return; + + int width=right-left; + int height=bottom-top; + float 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); + if(!animatingTransition) + updateViewTransform(false); + updateLimits(scale); + transX=transY=0; + } + + private float interpolate(float a, float b, float k){ + return a+(b-a)*k; + } + + @Override + protected boolean drawChild(Canvas canvas, View child, long drawingTime){ + if(!canvas.isHardwareAccelerated()) + return false; + if(child==this.child && animatingTransition){ + tmpRect.set(0, 0, child.getWidth(), child.getHeight()); + child.getMatrix().mapRect(tmpRect); + tmpRect.offset(child.getLeft(), child.getTop()); + tmpRect2.set(transitionCropRect); + child.getMatrix().mapRect(tmpRect2); + tmpRect2.offset(child.getLeft(), child.getTop()); + canvas.save(); + canvas.clipRect(interpolate(tmpRect2.left, tmpRect.left, cropAnimationValue), + interpolate(tmpRect2.top, tmpRect.top, cropAnimationValue), + interpolate(tmpRect2.right, tmpRect.right, cropAnimationValue), + interpolate(tmpRect2.bottom, tmpRect.bottom, cropAnimationValue)); + boolean res=super.drawChild(canvas, child, drawingTime); + canvas.restore(); + return res; + } + return super.drawChild(canvas, child, drawingTime); + } + + public void setListener(Listener listener){ + this.listener=listener; + } + + private void setCropAnimationValue(float val){ + cropAnimationValue=val; + } + + private float prepareTransitionCropRect(Rect rect){ + float initialScale; + float scaleW=rect.width()/(float)child.getWidth(); + float scaleH=rect.height()/(float)child.getHeight(); + if(scaleW>scaleH){ + initialScale=scaleW; + float scaledHeight=rect.height()/scaleW; + transitionCropRect.left=0; + transitionCropRect.right=child.getWidth(); + transitionCropRect.top=child.getHeight()/2f-scaledHeight/2f; + transitionCropRect.bottom=transitionCropRect.top+scaledHeight; + }else{ + initialScale=scaleH; + float scaledWidth=rect.width()/scaleH; + transitionCropRect.top=0; + transitionCropRect.bottom=child.getHeight(); + transitionCropRect.left=child.getWidth()/2f-scaledWidth/2f; + transitionCropRect.right=transitionCropRect.left+scaledWidth; + } + return initialScale; + } + + public void animateIn(Rect rect, int[] cornerRadius){ + int[] loc={0, 0}; + getLocationOnScreen(loc); + int centerX=loc[0]+getWidth()/2; + int centerY=loc[1]+getHeight()/2; + float initialTransX=rect.centerX()-centerX; + float initialTransY=rect.centerY()-centerY; + child.setTranslationX(initialTransX); + child.setTranslationY(initialTransY); + float initialScale=prepareTransitionCropRect(rect); + child.setScaleX(initialScale); + child.setScaleY(initialScale); + animatingTransition=true; + + matrix.getValues(matrixValues); + + child.setAlpha(0f); + setupAndStartTransitionAnim(new SpringAnimation(this, CROP_AND_FADE, 1f).setMinimumVisibleChange(DynamicAnimation.MIN_VISIBLE_CHANGE_SCALE)); + setupAndStartTransitionAnim(new SpringAnimation(child, DynamicAnimation.SCALE_X, matrixValues[Matrix.MSCALE_X])); + setupAndStartTransitionAnim(new SpringAnimation(child, DynamicAnimation.SCALE_Y, matrixValues[Matrix.MSCALE_Y])); + setupAndStartTransitionAnim(new SpringAnimation(child, DynamicAnimation.TRANSLATION_X, matrixValues[Matrix.MTRANS_X])); + setupAndStartTransitionAnim(new SpringAnimation(child, DynamicAnimation.TRANSLATION_Y, matrixValues[Matrix.MTRANS_Y])); + postOnAnimation(new Runnable(){ + @Override + public void run(){ + if(animatingTransition){ + listener.onTransitionAnimationUpdate(child.getTranslationX()-initialTransX, child.getTranslationY()-initialTransY, child.getScaleX()/initialScale); + postOnAnimation(this); + } + } + }); + } + + public void animateOut(Rect rect, int[] cornerRadius, float velocityY){ + int[] loc={0, 0}; + getLocationOnScreen(loc); + int centerX=loc[0]+getWidth()/2; + int centerY=loc[1]+getHeight()/2; + float initialTransX=rect.centerX()-centerX; + float initialTransY=rect.centerY()-centerY; + float initialScale=prepareTransitionCropRect(rect); + animatingTransition=true; + dismissAfterTransition=true; + rawCropAndFadeValue=1f; + + setupAndStartTransitionAnim(new SpringAnimation(this, CROP_AND_FADE, 0f).setMinimumVisibleChange(DynamicAnimation.MIN_VISIBLE_CHANGE_SCALE)); + setupAndStartTransitionAnim(new SpringAnimation(child, DynamicAnimation.SCALE_X, initialScale)); + setupAndStartTransitionAnim(new SpringAnimation(child, DynamicAnimation.SCALE_Y, initialScale)); + setupAndStartTransitionAnim(new SpringAnimation(child, DynamicAnimation.TRANSLATION_X, initialTransX)); + setupAndStartTransitionAnim(new SpringAnimation(child, DynamicAnimation.TRANSLATION_Y, initialTransY).setStartVelocity(velocityY)); + postOnAnimation(new Runnable(){ + @Override + public void run(){ + if(animatingTransition){ + listener.onTransitionAnimationUpdate(child.getTranslationX()-initialTransX, child.getTranslationY()-initialTransY, child.getScaleX()/initialScale); + postOnAnimation(this); + } + } + }); + } + + private void updateViewTransform(boolean animated){ + matrix.getValues(matrixValues); + if(animated){ + animatingTransform=true; + setupAndStartTransformAnim(new SpringAnimation(child, DynamicAnimation.SCALE_X, matrixValues[Matrix.MSCALE_X])); + setupAndStartTransformAnim(new SpringAnimation(child, DynamicAnimation.SCALE_Y, matrixValues[Matrix.MSCALE_Y])); + setupAndStartTransformAnim(new SpringAnimation(child, DynamicAnimation.TRANSLATION_X, matrixValues[Matrix.MTRANS_X])); + setupAndStartTransformAnim(new SpringAnimation(child, DynamicAnimation.TRANSLATION_Y, matrixValues[Matrix.MTRANS_Y])); + if(backgroundAlphaForTransition<1f){ + setupAndStartTransformAnim(new SpringAnimation(this, new FloatPropertyCompat<>("backgroundAlpha"){ + @Override + public float getValue(ZoomPanView object){ + return backgroundAlphaForTransition; + } + + @Override + public void setValue(ZoomPanView object, float value){ + backgroundAlphaForTransition=value; + listener.onSetBackgroundAlpha(value); + } + }, 1f).setMinimumVisibleChange(DynamicAnimation.MIN_VISIBLE_CHANGE_ALPHA)); + } + }else{ + if(animatingTransition) + Log.w(TAG, "updateViewTransform: ", new Throwable().fillInStackTrace()); + child.setScaleX(matrixValues[Matrix.MSCALE_X]); + child.setScaleY(matrixValues[Matrix.MSCALE_Y]); + child.setTranslationX(matrixValues[Matrix.MTRANS_X]); + child.setTranslationY(matrixValues[Matrix.MTRANS_Y]); + } + } + + private void updateLimits(float targetScale){ + float scaledWidth=child.getWidth()*targetScale; + float scaledHeight=child.getHeight()*targetScale; + if(scaledWidth>getWidth()){ + minTransX=(getWidth()-scaledWidth)/2f; + maxTransX=-minTransX; + }else{ + minTransX=maxTransX=0f; + } + if(scaledHeight>getHeight()){ + minTransY=(getHeight()-scaledHeight)/2f; + maxTransY=-minTransY; + }else{ + minTransY=maxTransY=0f; + } + } + + private void springBack(){ + if(child.getScaleX()maxScale){ + float scaleCorrection=maxScale/child.getScaleX(); + matrix.postScale(scaleCorrection, scaleCorrection, lastScaleCenterX, lastScaleCenterY); + matrix.getValues(matrixValues); + transX=matrixValues[Matrix.MTRANS_X]; + transY=matrixValues[Matrix.MTRANS_Y]; + updateLimits(maxScale); + needAnimate=true; + } + needAnimate|=clampMatrixTranslationToLimits(); + if(needAnimate){ + updateViewTransform(true); + }else if(animatingCanceledDismiss){ + animatingCanceledDismiss=false; + } + } + + private boolean clampMatrixTranslationToLimits(){ + boolean needAnimate=false; + float dtx=0f, dty=0f; + if(transX>maxTransX){ + dtx=maxTransX-transX; + transX=maxTransX; + needAnimate=true; + }else if(transXmaxTransY){ + dty=maxTransY-transY; + transY=maxTransY; + needAnimate=true; + }else if(transY animation, boolean canceled, float value, float velocity){ + runningTransformAnimations.remove(animation); + if(runningTransformAnimations.isEmpty()){ + animatingTransform=false; + if(animatingCanceledDismiss){ + animatingCanceledDismiss=false; + listener.onSwipeToDismissCanceled(); + } + } + } + + private void onTransitionAnimationEnd(DynamicAnimation animation, boolean canceled, float value, float velocity){ + runningTransitionAnimations.remove(animation); + if(runningTransitionAnimations.isEmpty()){ + animatingTransition=false; + wasAnimatingTransition=true; + listener.onTransitionAnimationFinished(); + if(dismissAfterTransition) + listener.onDismissed(); + else + invalidate(); + } + } + + private void setupAndStartTransformAnim(SpringAnimation anim){ + anim.getSpring().setStiffness(SpringForce.STIFFNESS_LOW).setDampingRatio(SpringForce.DAMPING_RATIO_NO_BOUNCY); + anim.addEndListener(this::onTransformAnimationEnd).start(); + runningTransformAnimations.add(anim); + } + + private void setupAndStartTransitionAnim(SpringAnimation anim){ + anim.getSpring().setStiffness(SpringForce.STIFFNESS_LOW).setDampingRatio(SpringForce.DAMPING_RATIO_LOW_BOUNCY); + anim.addEndListener(this::onTransitionAnimationEnd).start(); + runningTransitionAnimations.add(anim); + } + + @SuppressLint("ClickableViewAccessibility") + @Override + public boolean onTouchEvent(MotionEvent ev){ + boolean isUp=ev.getAction()==MotionEvent.ACTION_UP || ev.getAction()==MotionEvent.ACTION_CANCEL; + if(animatingTransition || (wasAnimatingTransition && ev.getAction()!=MotionEvent.ACTION_DOWN)) + return true; + scaleDetector.onTouchEvent(ev); + if(!swipingToDismiss && isUp){ + if(scrolling || wasScaling){ + scrolling=false; + wasScaling=false; + springBack(); + } + } + if(scaling) + return true; + gestureDetector.onTouchEvent(ev); + if(swipingToDismiss && isUp){ + swipingToDismiss=false; + scrolling=false; + if(Math.abs(child.getTranslationY())>getHeight()/4f){ + listener.onStartSwipeToDismissTransition(lastFlingVelocityY); + }else{ + animatingCanceledDismiss=true; + springBack(); + } + } + return true; + } + + @Override + public boolean onScale(ScaleGestureDetector detector){ + float factor=detector.getScaleFactor(); + matrix.postScale(factor, factor, detector.getFocusX()-getWidth()/2f, detector.getFocusY()-getHeight()/2f); + updateViewTransform(false); + lastScaleCenterX=detector.getFocusX()-getWidth()/2f; + lastScaleCenterY=detector.getFocusY()-getHeight()/2f; + return true; + } + + @Override + public boolean onScaleBegin(ScaleGestureDetector detector){ + requestDisallowInterceptTouchEvent(true); + scaling=true; + wasScaling=true; + return true; + } + + @Override + public void onScaleEnd(ScaleGestureDetector detector){ + scaling=false; + updateLimits(child.getScaleX()); + transX=child.getTranslationX(); + transY=child.getTranslationY(); + } + + @Override + public boolean onSingleTapConfirmed(MotionEvent e){ + return false; + } + + @Override + public boolean onDoubleTap(MotionEvent e){ + return true; + } + + @Override + public boolean onDoubleTapEvent(MotionEvent e){ + if(e.getAction()==MotionEvent.ACTION_UP){ + if(e.getEventTime()-e.getDownTime()Math.abs(totalScrollX)){ + if(!swipingToDismiss){ + swipingToDismiss=true; + matrix.postTranslate(-totalScrollX, 0); + transX-=totalScrollX; + listener.onStartSwipeToDismiss(); + } + matrix.postTranslate(0, -distanceY); + transY-=distanceY; + updateViewTransform(false); + float alpha=1f-Math.abs(transY)/getHeight(); + backgroundAlphaForTransition=alpha; + listener.onSetBackgroundAlpha(alpha); + return true; + } + }else{ + distanceY=0; + } + } + totalScrollX-=distanceX; + totalScrollY-=distanceY; + matrix.postTranslate(-distanceX, -distanceY); + transX-=distanceX; + transY-=distanceY; + boolean atEdge=false; + if(transXmaxTransX && canScrollLeft){ + matrix.postTranslate(maxTransX-transX, 0f); + transX=maxTransX; + atEdge=true; + } + updateViewTransform(false); + if(!scrolling){ + scrolling=true; + // if the image is at the edge horizontally, or the user is dragging more vertically, intercept; + // otherwise, give these touch events to the view pager to scroll pages + requestDisallowInterceptTouchEvent(!atEdge || Math.abs(totalScrollX)=V.dp(1000)){ + swipingToDismiss=false; + scrolling=false; + listener.onStartSwipeToDismissTransition(velocityY); + } + }else if(!animatingTransform){ + scroller.fling(Math.round(transX), Math.round(transY), Math.round(velocityX), Math.round(velocityY), Math.round(minTransX), Math.round(maxTransX), Math.round(minTransY), Math.round(maxTransY), 0, 0); + postOnAnimation(scrollerUpdater); + } + return true; + } + + private void doScrollerAnimation(){ + if(scroller.computeScrollOffset()){ + float dx=transX-scroller.getCurrX(); + float dy=transY-scroller.getCurrY(); + transX-=dx; + transY-=dy; + matrix.postTranslate(-dx, -dy); + updateViewTransform(false); + postOnAnimation(scrollerUpdater); + } + } + + public interface Listener{ + void onTransitionAnimationUpdate(float translateX, float translateY, float scale); + void onTransitionAnimationFinished(); + void onSetBackgroundAlpha(float alpha); + void onStartSwipeToDismiss(); + void onStartSwipeToDismissTransition(float velocityY); + void onSwipeToDismissCanceled(); + void onDismissed(); + } +} diff --git a/mastodon/src/main/res/layout/display_item_photo.xml b/mastodon/src/main/res/layout/display_item_photo.xml index 00338c86..10abd123 100644 --- a/mastodon/src/main/res/layout/display_item_photo.xml +++ b/mastodon/src/main/res/layout/display_item_photo.xml @@ -5,8 +5,9 @@ \ No newline at end of file