Photo viewer!

This commit is contained in:
Grishka 2022-01-22 22:34:05 +03:00
parent dc836b58f8
commit c3b7fb7002
8 changed files with 974 additions and 6 deletions

View File

@ -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'

View File

@ -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<Status>{
public abstract class StatusListFragment extends BaseRecyclerFragment<Status> implements PhotoViewerHost{
protected ArrayList<StatusDisplayItem> 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<Status>{
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<BindableViewHolder<StatusDisplayItem>> implements ImageLoaderRecyclerAdapter{
public DisplayItemsAdapter(){

View File

@ -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<PhotoStatusDisplayItem> 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));
}
}
}
}

View File

@ -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;

View File

@ -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<Attachment> 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<Attachment> 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 <code>{@link #endPhotoViewTransition()}</code> 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 <b>in screen coordinates</b>
* @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<PhotoViewHolder>{
@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<Attachment> 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()<attachments.size()-1);
ViewImageLoader.load(this, listener.getPhotoViewCurrentDrawable(getAbsoluteAdapterPosition()), new UrlImageLoaderRequest(item.url), false);
}
@Override
public void setImageDrawable(Drawable d){
imageView.setImageDrawable(d);
}
@Override
public View getView(){
return imageView;
}
}
}

View File

@ -0,0 +1,7 @@
package org.joinmastodon.android.ui.photoviewer;
import org.joinmastodon.android.model.Status;
public interface PhotoViewerHost{
void openPhotoViewer(Status status, int attachmentIndex);
}

View File

@ -0,0 +1,591 @@
package org.joinmastodon.android.ui.photoviewer;
import android.animation.ValueAnimator;
import android.annotation.SuppressLint;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Matrix;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.RectF;
import android.util.AttributeSet;
import android.util.Log;
import android.view.GestureDetector;
import android.view.MotionEvent;
import android.view.ScaleGestureDetector;
import android.view.View;
import android.view.ViewConfiguration;
import android.widget.FrameLayout;
import android.widget.OverScroller;
import java.util.ArrayList;
import androidx.dynamicanimation.animation.DynamicAnimation;
import androidx.dynamicanimation.animation.FloatPropertyCompat;
import androidx.dynamicanimation.animation.SpringAnimation;
import androidx.dynamicanimation.animation.SpringForce;
import me.grishka.appkit.utils.V;
public class ZoomPanView extends FrameLayout implements ScaleGestureDetector.OnScaleGestureListener, GestureDetector.OnGestureListener, GestureDetector.OnDoubleTapListener{
private View child;
private Matrix matrix=new Matrix();
private float[] matrixValues=new float[9];
private ScaleGestureDetector scaleDetector;
private GestureDetector gestureDetector;
private OverScroller scroller;
private boolean scaling, scrolling, swipingToDismiss, wasScaling, animatingTransform, animatingTransition, dismissAfterTransition, animatingCanceledDismiss;
private boolean wasAnimatingTransition; // to drop any sequences of touch events that start during animation but continue after it
// these keep track of view translation/scrolling
private float transX, transY;
// translation/scrolling limits, updated whenever scale changes
private float minTransX, minTransY, maxTransX, maxTransY;
// total scroll offsets since the last ACTION_DOWN event, to detect scrolling axis
private float totalScrollX, totalScrollY;
// scale factor limits
private float minScale, maxScale;
// coordinates of the last scale gesture, to undo extra if it goes above maxScale
private float lastScaleCenterX, lastScaleCenterY;
private boolean canScrollLeft, canScrollRight;
private ArrayList<SpringAnimation> 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<ZoomPanView> 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()<minScale){
matrix.setScale(minScale, minScale);
updateViewTransform(true);
updateLimits(minScale);
transX=transY=0;
return;
}
boolean needAnimate=false;
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(transX<minTransX){
dtx=minTransX-transX;
transX=minTransX;
needAnimate=true;
}
if(transY>maxTransY){
dty=maxTransY-transY;
transY=maxTransY;
needAnimate=true;
}else if(transY<minTransY){
dty=minTransY-transY;
transY=minTransY;
needAnimate=true;
}
if(needAnimate)
matrix.postTranslate(dtx, dty);
return needAnimate;
}
public void setScrollDirections(boolean left, boolean right){
canScrollLeft=left;
canScrollRight=right;
}
private void onTransformAnimationEnd(DynamicAnimation<?> 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()<ViewConfiguration.getTapTimeout()){
if(animatingTransform)
return false;
if(child.getScaleX()<maxScale){
float scale=maxScale/child.getScaleX();
matrix.postScale(scale, scale, e.getX()-getWidth()/2f, e.getY()-getHeight()/2f);
matrix.getValues(matrixValues);
transX=matrixValues[Matrix.MTRANS_X];
transY=matrixValues[Matrix.MTRANS_Y];
updateLimits(maxScale);
clampMatrixTranslationToLimits();
updateViewTransform(true);
}else{
matrix.setScale(minScale, minScale);
updateLimits(minScale);
transX=transY=0;
updateViewTransform(true);
}
return true;
}
}
return false;
}
@Override
public boolean onDown(MotionEvent e){
totalScrollX=totalScrollY=0;
lastFlingVelocityY=0;
wasAnimatingTransition=false;
if(!scroller.isFinished()){
scroller.forceFinished(true);
removeCallbacks(scrollerUpdater);
}
requestDisallowInterceptTouchEvent(true);
return false;
}
@Override
public void onShowPress(MotionEvent e){}
@Override
public boolean onSingleTapUp(MotionEvent e){
return false;
}
@Override
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(!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(transX<minTransX && canScrollRight){
matrix.postTranslate(minTransX-transX, 0f);
transX=minTransX;
atEdge=true;
}else if(transX>maxTransX && 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)<Math.abs(totalScrollY));
}
return true;
}
@Override
public void onLongPress(MotionEvent e){}
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY){
if(swipingToDismiss){
lastFlingVelocityY=velocityY;
if(Math.abs(velocityY)>=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();
}
}

View File

@ -5,8 +5,9 @@
<ImageView
android:id="@+id/photo"
android:layout_width="match_parent"
android:layout_width="250dp"
android:layout_height="250dp"
android:layout_gravity="center"
android:scaleType="centerCrop"/>
</FrameLayout>