1. ZoomLayout 需要實現(xiàn)的功能
1.1 需求列表
- 觸摸滑動及慣性滑動
- 多指縮放
- 雙擊縮放
除了實現(xiàn)這些主要功能外噪服,還需要處理一下的細(xì)節(jié)
-
ZoomLayout
的寬高大于子View
時巷波,子View
居中顯示 -
ZoomLayout
需要響應(yīng)事件浑玛,但是不能把事件攔截掉 - 處理滑動沖突蟆盐,比如將
ZoomLayout
放在ViewPager
中
1.2 使用舉例
<?xml version="1.0" encoding="utf-8"?>
<com.xuliwen.zoom.ZoomLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:max_zoom="3.0"
app:min_zoom="1.0"
app:double_click_zoom="2.0">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<ImageView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:scaleType="fitXY"
android:adjustViewBounds="true"
android:src="@mipmap/image1"/>
<ImageView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:scaleType="fitXY"
android:adjustViewBounds="true"
android:src="@mipmap/image2"/>
</LinearLayout>
1.3 效果和源碼
效果如下:
ZoomLayout 源碼 里面有完整的代碼、Demo厦瓢、使用說明
2. 實現(xiàn)
2.1 基礎(chǔ)知識
在講具體實現(xiàn)之前揍障,先提一下會用到的一些基礎(chǔ)的知識,不了解的同學(xué)可以先去了解一下
-
GestureDetector
用于獲取單擊救军、雙擊财异、滾動、拋擲 等動作 -
ScaleGestureDetector
用于獲取縮放的動作 -
OverScroller
滾動的輔助類
2.2 重寫 measureChildWithMargins
@Override
protected void measureChildWithMargins(View child, int parentWidthMeasureSpec, int widthUsed,
int parentHeightMeasureSpec, int heightUsed) {
final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec,
getPaddingLeft() + getPaddingRight() + lp.leftMargin + lp.rightMargin
+ widthUsed, lp.width);
final int usedTotal = getPaddingTop() + getPaddingBottom() + lp.topMargin + lp.bottomMargin +
heightUsed;
final int childHeightMeasureSpec;
if (lp.height == WRAP_CONTENT) {
childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(
Math.max(0, MeasureSpec.getSize(parentHeightMeasureSpec) - usedTotal),
MeasureSpec.UNSPECIFIED);
} else {
childHeightMeasureSpec = getChildMeasureSpec(parentHeightMeasureSpec,
getPaddingTop() + getPaddingBottom() + lp.topMargin + lp.bottomMargin
+ heightUsed, lp.height);
}
child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}
ZoomLayout
繼承了 LinearLayout
后唱遭,發(fā)現(xiàn)屏幕外的 View
是沒有繪制出來的戳寸,但是我們平時使用 ScrollView
的時候,屏幕外的 View
也能繪制出來拷泽,查看 ScrollView
的源碼疫鹊,發(fā)現(xiàn)它重寫了 measureChildWithMargins
方法。
2.3 實現(xiàn)滾動
private GestureDetector.SimpleOnGestureListener mSimpleOnGestureListener = new GestureDetector.SimpleOnGestureListener() {
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
if (!isEnabled()) {
return false;
}
processScroll((int) distanceX, (int) distanceY, getScrollRangeX(), getScrollRangeY());
return true;
}
};
private void processScroll(int deltaX, int deltaY,
int scrollRangeX, int scrollRangeY) {
int oldScrollX = getScrollX();
int oldScrollY = getScrollY();
int newScrollX = oldScrollX + deltaX;
int newScrollY = oldScrollY + deltaY;
final int left = 0;
final int right = scrollRangeX;
final int top = 0;
final int bottom = scrollRangeY;
if (newScrollX > right) {
newScrollX = right;
} else if (newScrollX < left) {
newScrollX = left;
}
if (newScrollY > bottom) {
newScrollY = bottom;
} else if (newScrollY < top) {
newScrollY = top;
}
if (newScrollX < 0) {
newScrollX = 0;
}
if (newScrollY < 0) {
newScrollY = 0;
}
scrollTo(newScrollX, newScrollY);
}
滾動可以在 onScroll
回調(diào)拿到司致,distanceX
拆吆、distanceY
分別是 X 軸和 Y 軸上拿到的
滾動距離,getScrollX()
脂矫、getScrollY()
則拿到了當(dāng)前的滾動距離枣耀,這樣就可以算出
newScrollX
和 newScrollY
了,最后調(diào)用 scrollTo
進(jìn)行滾動 羹唠。還有一點要注意的是奕枢,scrollX
和 scrollY
都有滾動范圍娄昆,實現(xiàn)如下:
// mCurrentZoom 是當(dāng)前的縮放值;ScrollRange 大于 0 的時候說明可以滾動
private int getScrollRangeX() {
final int contentWidth = getWidth() - getPaddingRight() - getPaddingLeft();
return (getContentWidth() - contentWidth);
}
private int getContentWidth() {
return (int) (child().getWidth() * mCurrentZoom);
}
private int getScrollRangeY() {
final int contentHeight = getHeight() - getPaddingBottom() - getPaddingTop();
return getContentHeight() - contentHeight;
}
private int getContentHeight() {
return (int) (child().getHeight() * mCurrentZoom);
}
private View child() {
return getChildAt(0);
}
2.4 實現(xiàn) Fling(拋擲)滾動
Fling
滾動就是我們往某個方向快速滑動缝彬,當(dāng)我們手指抬起后萌焰,View
還會沿著某個方向繼續(xù)滾動。
private GestureDetector.SimpleOnGestureListener mSimpleOnGestureListener = new GestureDetector.SimpleOnGestureListener() {
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
if (!isEnabled()) {
return false;
}
fling((int) -velocityX, (int) -velocityY);
return true;
}
};
private boolean fling(int velocityX, int velocityY) {
if (Math.abs(velocityX) < mMinimumVelocity) {
velocityX = 0;
}
if (Math.abs(velocityY) < mMinimumVelocity) {
velocityY = 0;
}
final int scrollY = getScrollY();
final int scrollX = getScrollX();
final boolean canFlingX = (scrollX > 0 || velocityX > 0) &&
(scrollX < getScrollRangeX() || velocityX < 0);
final boolean canFlingY = (scrollY > 0 || velocityY > 0) &&
(scrollY < getScrollRangeY() || velocityY < 0);
boolean canFling = canFlingY || canFlingX;
if (canFling) {
velocityX = Math.max(-mMaximumVelocity, Math.min(velocityX, mMaximumVelocity));
velocityY = Math.max(-mMaximumVelocity, Math.min(velocityY, mMaximumVelocity));
int height = getHeight() - getPaddingBottom() - getPaddingTop();
int width = getWidth() - getPaddingRight() - getPaddingLeft();
int bottom = getContentHeight();
int right = getContentWidth();
mOverScroller.fling(getScrollX(), getScrollY(), velocityX, velocityY, 0, Math.max(0, right - width), 0,
Math.max(0, bottom - height), 0, 0);
notifyInvalidate();
return true;
}
return false;
}
private void notifyInvalidate() {
// 效果和 invalidate 一樣谷浅,但是會使得動畫更平滑
ViewCompat.postInvalidateOnAnimation(this);
}
@Override
public void computeScroll() {
super.computeScroll();
if (mOverScroller.computeScrollOffset()) { // 判斷是否可以滾動
int oldX = getScrollX();
int oldY = getScrollY();
int x = mOverScroller.getCurrX();
int y = mOverScroller.getCurrY();
if (oldX != x || oldY != y) {
final int rangeY = getScrollRangeY();
final int rangeX = getScrollRangeX();
processScroll(x - oldX, y - oldY, rangeX, rangeY);
}
if (!mOverScroller.isFinished()) { // 如果滾動沒有停止扒俯,那就再調(diào)用一次 notifyInvalidate(),會觸發(fā)下一次的 computeScroll()
notifyInvalidate();
}
}
}
大體的思路是通過 onFling()
拿到手勢一疯,然后計算是否能夠滑動撼玄,可以的話就調(diào)用
mOverScroller.fling()
開始滾動,最后不要忘了調(diào)用 notifyInvalidate()
墩邀。調(diào)用
notifyInvalidate()
后我們就可以在 computeScroll()
回調(diào)中使用 processScroll()
進(jìn)行滾動
2.5 手勢縮放
private ScaleGestureDetector.SimpleOnScaleGestureListener mSimpleOnScaleGestureListener = new ScaleGestureDetector.SimpleOnScaleGestureListener() {
@Override
public boolean onScale(ScaleGestureDetector detector) {
if (!isEnabled()) {
return false;
}
float newScale;
newScale = mCurrentZoom * detector.getScaleFactor();
if (newScale > mMaxZoom) {
newScale = mMaxZoom;
} else if (newScale < mMinZoom) {
newScale = mMinZoom;
}
setScale(newScale, (int) detector.getFocusX(), (int) detector.getFocusY());
return true;
}
@Override
public boolean onScaleBegin(ScaleGestureDetector detector) {
return true;
}
@Override
public void onScaleEnd(ScaleGestureDetector detector) {
}
};
public void setScale(float scale, int centerX, int centerY) {
float preScale = mCurrentZoom;
mCurrentZoom = scale;
int sX = getScrollX();
int sY = getScrollY();
int dx = (int) ((sX + centerX) * (scale / preScale - 1));
int dy = (int) ((sY + centerY) * (scale / preScale - 1));
child().setPivotX(0);
child().setPivotY(0);
child().setScaleX(mCurrentZoom);
child().setScaleY(mCurrentZoom);
processScroll(dx, dy, getScrollRangeX(), getScrollRangeY());
notifyInvalidate();
}
實現(xiàn)思路是 onScale()
中拿到縮放手勢掌猛,縮放不僅會影響到 scale
,其實還會影響到
scrollX
眉睹、scrollY
荔茬,所以縮放的時候,也要調(diào)用 processScroll()
竹海。由于我們的 scrollX
慕蔚、scrollY
是基于 ZoomLayout
的左上角計算的(這里先默認(rèn)子 View 左上角和 ZoomLayout
左上角已知,后面還需要適配這一點)斋配,所以我們這里的縮放也要基于左上角計算孔飒,通過 setPivotX(0)
,setPivotY(0)
設(shè)置縮放中心點為左上角
2.6 雙擊縮放
private GestureDetector.SimpleOnGestureListener mSimpleOnGestureListener = new GestureDetector.SimpleOnGestureListener() {
@Override
public boolean onDoubleTap(MotionEvent e) {
float newScale;
if (mCurrentZoom < 1) {
newScale = 1;
} else if (mCurrentZoom < mDoubleClickZoom) {
newScale = mDoubleClickZoom;
} else {
newScale = 1;
}
smoothScale(newScale, (int) e.getX(), (int) e.getY());
return true;
}
};
public void smoothScale(float newScale, int centerX, int centerY) {
if (mCurrentZoom > newScale) {
if (mAccelerateInterpolator == null) {
mAccelerateInterpolator = new AccelerateInterpolator();
}
mScaleHelper.startScale(mCurrentZoom, newScale, centerX, centerY, mAccelerateInterpolator);
} else {
if (mDecelerateInterpolator == null) {
mDecelerateInterpolator = new DecelerateInterpolator();
}
mScaleHelper.startScale(mCurrentZoom, newScale, centerX, centerY, mDecelerateInterpolator);
}
notifyInvalidate();
}
@Override
public void computeScroll() {
super.computeScroll();
if (mScaleHelper.computeScrollOffset()) {
setScale(mScaleHelper.getCurScale(), mScaleHelper.getStartX(), mScaleHelper.getStartY());
}
}
雙擊縮放和手勢縮放都是縮放艰争,不同點在于雙擊縮放我們需要自己去計算每個時間點的
scale坏瞄,比如說雙擊后,View
會在 200 ms 內(nèi)從 1倍 scale 變成 2倍 scale甩卓,那么我們就要自己去計算 200ms惦积,scale 的變化∶推担看到這里大家都應(yīng)該想到了其實就是對 scale 這個值做一個屬性動畫嘛。這里將其封裝在了 ScaleHelper
中蛛勉。跟 Fling 的思路一樣鹿寻,通過 notifyInvalidate() 和 computeScroll() 實現(xiàn)循環(huán)。
3. 其他功能
3.1 適配 ViewPager
很簡單诽凌,ViewPager
會通過子 View
的 canScrollHorizontally
和 canScrollVertically
判斷是否可以橫向毡熏、豎向滾動,ZoomLayout
重寫他們就是了
@Override
public boolean canScrollHorizontally(int direction) {
if (direction > 0) {
return getScrollX() < getScrollRangeX();
} else {
return getScrollX() > 0 && getScrollRangeX() > 0;
}
}
@Override
public boolean canScrollVertically(int direction) {
if (direction > 0) {
return getScrollY() < getScrollRangeY();
} else {
return getScrollY() > 0 && getScrollRangeY() > 0;
}
}
3.2 事件傳遞
我們不希望 ZoomLayout 或者子 View 把事件消耗掉侣诵,而是兩者都能收到事件痢法。
下面是我的實現(xiàn):
ZoomLayout
在 dispatchTouchEvent
去接收事件狱窘,因為這樣即使子 View
消耗了事件,事件依然會經(jīng)過這里财搁。
然后在 onDraw
設(shè)置 child().setClickable(true)
蘸炸,這里是為了讓事件能被
子 View 消耗掉,因為只有子 View 消耗了事件尖奔,事件才能一直傳遞到 ZoomLayout
中去搭儒。
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
mGestureDetector.onTouchEvent(ev);
mScaleDetector.onTouchEvent(ev);
return super.dispatchTouchEvent(ev);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
child().setClickable(true);
}
3.3 布局的控制
我們希望 ZoomLayout
的寬高比子 View 的寬高大的時候,居中顯示提茁,否則就顯示為
left|top
淹禾,我的思路是我們可以在 onDraw
中拿到準(zhǔn)確的寬、高茴扁,通過寬高的對比铃岔,決定使用什么布局
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
child().setClickable(true);
if (child().getHeight() < getHeight() || child().getWidth() < getWidth()) {
setGravity(Gravity.CENTER);
} else {
setGravity(Gravity.TOP);
}
}
當(dāng)適配到這里的時候,我們會發(fā)現(xiàn)一個問題峭火,比如子 View 的高度小于 ZoomLayout
的時候毁习,我們是安裝子 View 的中心放大,如果這是我們放大子 View躲胳,放大到子 View 高度大于 ZoomLayout
蜓洪,我們這時候需要將子 View
translate 到 ZoomLayout
的頂部,原因是我們第 2.5 步說到的 scroolX坯苹、scrollY 是基于左上角計算的隆檀。所以適配后的代碼是這樣的
public void setScale(float scale, int centerX, int centerY) {
float preScale = mCurrentZoom;
mCurrentZoom = scale;
int sX = getScrollX();
int sY = getScrollY();
int dx = (int) ((sX + centerX) * (scale / preScale - 1));
int dy = (int) ((sY + centerY) * (scale / preScale - 1));
if (getScrollRangeX() < 0) {
child().setPivotX(child().getWidth() / 2);
child().setTranslationX(0);
} else {
child().setPivotX(0);
int willTranslateX = -(child().getLeft());
child().setTranslationX(willTranslateX);
}
if (getScrollRangeY() < 0) {
child().setPivotY(child().getHeight() / 2);
child().setTranslationY(0);
} else {
int willTranslateY = -(child().getTop());
child().setTranslationY(willTranslateY);
child().setPivotY(0);
}
child().setScaleX(mCurrentZoom);
child().setScaleY(mCurrentZoom);
processScroll(dx, dy, getScrollRangeX(), getScrollRangeY());
notifyInvalidate();
}
在適配業(yè)務(wù)的過程,遇到了另外一個問題粹湃,就是 ZoomLayout 的高度有可能是會發(fā)生變化的恐仑,比如鍵盤彈出來的時候,ZoomLayout 可能會被壓小为鳄,
我的思路是在 onDraw
中監(jiān)聽寬高的變化裳仆,有變化的時候,調(diào)用 setScale
去設(shè)置為正確的狀態(tài)孤钦。
public void setScale(float scale, int centerX, int centerY) {
// 記下最近一次的狀態(tài)
mLastCenterX = centerX;
mLastCenterY = centerY;
mCurrentZoom = scale;
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
super.onLayout(changed, l, t, r, b);
if (mNeedReScale) {
// 需要重新刷新歧斟,因為寬高已經(jīng)發(fā)生變化
setScale(mCurrentZoom, mLastCenterX, mLastCenterY);
mNeedReScale = false;
}
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
if (mLastChildWidth != child().getWidth() || mLastChildHeight != child().getHeight() || mLastWidth != getWidth()
|| mLastHeight != getHeight()) {
// 寬高變化后,記錄需要重新刷新偏形,放在下次 onLayout 處理静袖,避免 View 的一些配置:比如 getTop() 沒有初始化好
// 下次放在 onLayout 處理的原因是 setGravity 會在 onLayout 確定完位置,這時候去 setScale 導(dǎo)致位置的變化就不會導(dǎo)致用戶看到
// 閃一下的問題
mNeedReScale = true;
}
mLastChildWidth = child().getWidth();
mLastChildHeight = child().getHeight();
mLastWidth = child().getWidth();
mLastHeight = getHeight();
if (mNeedReScale) {
notifyInvalidate();
}
}
上面有個小細(xì)節(jié)是發(fā)現(xiàn) mNeedReScale 為 true 時沒有立即調(diào)用 setScale
俊扭,因為這時候 setGravity
還沒有生效队橙,我把它放在了下一次
onLayout
中
總結(jié)
實現(xiàn)一個 ZoomLayout 主要是需要熟悉手勢的使用,然后實現(xiàn)過程中比較難也比較麻煩的是各種坐標(biāo)相關(guān)的計算,以及各種細(xì)節(jié)的適配捐康。實現(xiàn)過程中很多代碼都參考了 LargeImageView 仇矾、ScrollView
。
ZoomLayout
的實現(xiàn)還有很多改進(jìn)的地方解总,比如事件的處理等贮匕,歡迎交流~
ZoomLayout 源碼 里面有完整的代碼、Demo倾鲫、使用說明