RatioImageView實現ImageView按比例縮放等效果
0. 原始碼地址
1. 引用方法
compile 'com.zhxh:ximageviewlib:1.2'
複製程式碼
2. 使用方法
舉個栗子:
<com.zhxh.ximageviewlib.RatioImageView
android:id="@+id/ad_app_image"
android:layout_width="100dp"
android:layout_height="wrap_content"
android:scaleType="centerCrop"
android:src="@drawable/ic_test_750_360"
app:riv_height_to_width_ratio="0.48"
tools:ignore="ContentDescription" />
複製程式碼
上面riv_height_to_width_ratio=0.48 已經定義layout_width="100dp" 計算得出 layout_height="48dp"
實現效果
3. 原始碼實現
3.1 屬性定義與描述
<declare-styleable name="RatioImageView">
<!-- 寬度是否根據src圖片的比例來測量(高度已知) -->
<attr name="riv_is_width_fix_drawable_size_ratio" format="boolean" />
<!-- 高度是否根據src圖片的比例來測量(寬度已知) -->
<attr name="riv_is_height_fix_drawable_size_ratio" format="boolean" />
<!--當mIsWidthFitDrawableSizeRatio生效時,最大寬度-->
<attr name="riv_max_width_when_width_fix_drawable" format="dimension" />
<!--當mIsHeightFitDrawableSizeRatio生效時-->
<attr name="riv_max_height_when_height_fix_drawable" format="dimension" />
<!-- 高度設定,參考寬度,如0.5 , 表示 高度=寬度×0.5 -->
<attr name="riv_height_to_width_ratio" format="float" />
<!-- 寬度設定,參考高度,如0.5 , 表示 寬度=高度×0.5 -->
<attr name="riv_width_to_height_ratio" format="float" />
<!--寬度和高度,避免layout_width/layout_height會在超過螢幕尺寸時特殊處理的情況-->
<attr name="riv_width" format="dimension" />
<attr name="riv_height" format="dimension" />
</declare-styleable>
複製程式碼
3.2 程式碼實現
1,屬性初始化 從AttributeSet 中初始化相關屬性
private void init(AttributeSet attrs) {
TypedArray a = getContext().obtainStyledAttributes(attrs,
R.styleable.RatioImageView);
mIsWidthFitDrawableSizeRatio = a.getBoolean(R.styleable.RatioImageView_riv_is_width_fix_drawable_size_ratio,
mIsWidthFitDrawableSizeRatio);
mIsHeightFitDrawableSizeRatio = a.getBoolean(R.styleable.RatioImageView_riv_is_height_fix_drawable_size_ratio,
mIsHeightFitDrawableSizeRatio);
mMaxWidthWhenWidthFixDrawable = a.getDimensionPixelOffset(R.styleable.RatioImageView_riv_max_width_when_width_fix_drawable,
mMaxWidthWhenWidthFixDrawable);
mMaxHeightWhenHeightFixDrawable = a.getDimensionPixelOffset(R.styleable.RatioImageView_riv_max_height_when_height_fix_drawable,
mMaxHeightWhenHeightFixDrawable);
mHeightRatio = a.getFloat(
R.styleable.RatioImageView_riv_height_to_width_ratio, mHeightRatio);
mWidthRatio = a.getFloat(
R.styleable.RatioImageView_riv_width_to_height_ratio, mWidthRatio);
mDesiredWidth = a.getDimensionPixelOffset(R.styleable.RatioImageView_riv_width, mDesiredWidth);
mDesiredHeight = a.getDimensionPixelOffset(R.styleable.RatioImageView_riv_height, mDesiredHeight);
a.recycle();
}
複製程式碼
2,關鍵資料初始化
mDrawableSizeRatio = -1f; // src圖片(前景圖)的寬高比例
在建構函式中呼叫以下程式碼,當mDrawable不為空時
mDrawableSizeRatio = 1f * getDrawable().getIntrinsicWidth()
/ getDrawable().getIntrinsicHeight();
複製程式碼
其他變數初始化
private boolean mIsWidthFitDrawableSizeRatio; // 寬度是否根據src圖片(前景圖)的比例來測量(高度已知)
private boolean mIsHeightFitDrawableSizeRatio; // 高度是否根據src圖片(前景圖)的比例來測量(寬度已知)
private int mMaxWidthWhenWidthFixDrawable = -1; // 當mIsWidthFitDrawableSizeRatio生效時,最大寬度
private int mMaxHeightWhenHeightFixDrawable = -1; // 當mIsHeightFitDrawableSizeRatio生效時,最大高度
// 寬高比例
private float mWidthRatio = -1; // 寬度 = 高度*mWidthRatio
private float mHeightRatio = -1; // 高度 = 寬度*mHeightRatio
private int mDesiredWidth = -1; // 寬度和高度,避免layout_width/layout_height會在超過螢幕尺寸時特殊處理的情況
private int mDesiredHeight = -1;
複製程式碼
3,重新生成所需的drawable 我們覆蓋ImageView的setImageResource與setImageDrawable函式,對生成的drawable物件重新自定義
@Override
public void setImageResource(int resId) {
super.setImageResource(resId);
reSetDrawable();
}
@Override
public void setImageDrawable(Drawable drawable) {
super.setImageDrawable(drawable);
reSetDrawable();
}
複製程式碼
自定義所需的drawable物件
private void reSetDrawable() {
Drawable drawable = getDrawable();
if (drawable != null) {
// 發生變化,重新調整佈局
if (mIsWidthFitDrawableSizeRatio || mIsHeightFitDrawableSizeRatio) {
float old = mDrawableSizeRatio;
mDrawableSizeRatio = 1f * drawable.getIntrinsicWidth()
/ drawable.getIntrinsicHeight();
if (old != mDrawableSizeRatio && mDrawableSizeRatio > 0) {
requestLayout();
}
}
}
}
複製程式碼
從上面程式碼我們看到,當圖片本身比例與定義比例不同時,會呼叫 **requestLayout();**方法重新調整佈局。 該方法的作用是什麼呢? 我們進入 **requestLayout()**方法:
/**
* Call this when something has changed which has invalidated the
* layout of this view. This will schedule a layout pass of the view
* tree. This should not be called while the view hierarchy is currently in a layout
* pass ({@link #isInLayout()}. If layout is happening, the request may be honored at the
* end of the current layout pass (and then layout will run again) or after the current
* frame is drawn and the next layout occurs.
*
* <p>Subclasses which override this method should call the superclass method to
* handle possible request-during-layout errors correctly.</p>
*/
@CallSuper
public void requestLayout() {
if (mMeasureCache != null) mMeasureCache.clear();
if (mAttachInfo != null && mAttachInfo.mViewRequestingLayout == null) {
// Only trigger request-during-layout logic if this is the view requesting it,
// not the views in its parent hierarchy
ViewRootImpl viewRoot = getViewRootImpl();
if (viewRoot != null && viewRoot.isInLayout()) {
if (!viewRoot.requestLayoutDuringLayout(this)) {
return;
}
}
mAttachInfo.mViewRequestingLayout = this;
}
mPrivateFlags |= PFLAG_FORCE_LAYOUT;
mPrivateFlags |= PFLAG_INVALIDATED;
if (mParent != null && !mParent.isLayoutRequested()) {
mParent.requestLayout();
}
if (mAttachInfo != null && mAttachInfo.mViewRequestingLayout == this) {
mAttachInfo.mViewRequestingLayout = null;
}
}
複製程式碼
上面是Android view中該方法的定義,從程式碼中我們可以看出它首先先判斷當前View樹是否正在佈局流程,接著為當前子View設定標記位,該標記位的作用就是標記了當前的View是需要進行重新佈局的,接著呼叫mParent.requestLayout方法,這個十分重要,因為這裡是向父容器請求佈局,即呼叫父容器的requestLayout方法,為父容器新增PFLAG_FORCE_LAYOUT標記位,而父容器又會呼叫它的父容器的requestLayout方法,即requestLayout事件層層向上傳遞,直到DecorView,即根View,而根View又會傳遞給ViewRootImpl,也即是說子View的requestLayout事件,最終會被ViewRootImpl接收並得到處理。可以看出這種向上傳遞的流程,其實是採用了責任鏈模式,即不斷向上傳遞該事件,直到找到能處理該事件的上級,在這裡,只有ViewRootImpl能夠處理requestLayout事件。
@Override
public void requestLayout() {
if (!mHandlingLayoutInLayoutRequest) {
checkThread();
mLayoutRequested = true;
scheduleTraversals();
}
}
複製程式碼
我們進一步深入,可以看出在ViewRootImpl中,重寫了requestLayout方法。 在這裡,呼叫了scheduleTraversals方法,這個方法是一個非同步方法,最終會呼叫到ViewRootImpl#performTraversals方法,這也是View工作流程的核心方法,在這個方法內部,分別呼叫measure、layout、draw方法來進行View的三大工作流程,對於三大工作流程,前幾篇文章已經詳細講述了,這裡再做一點補充說明。 先看View#measure方法:
public final void measure(int widthMeasureSpec, int heightMeasureSpec) {
...
if ((mPrivateFlags & PFLAG_FORCE_LAYOUT) == PFLAG_FORCE_LAYOUT ||
widthMeasureSpec != mOldWidthMeasureSpec ||
heightMeasureSpec != mOldHeightMeasureSpec) {
...省略無關程式碼...
if (cacheIndex < 0 || sIgnoreMeasureCache) {
// measure ourselves, this should set the measured dimension flag back
onMeasure(widthMeasureSpec, heightMeasureSpec);
mPrivateFlags3 &= ~PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT;
}
...省略無關程式碼...
mPrivateFlags |= PFLAG_LAYOUT_REQUIRED;
}
}
複製程式碼
首先是判斷一下標記位,如果當前View的標記位為PFLAG_FORCE_LAYOUT,那麼就會進行測量流程,呼叫onMeasure,對該View進行測量,接著最後為標記位設定為PFLAG_LAYOUT_REQUIRED,這個標記位的作用就是在View的layout流程中,如果當前View設定了該標記位,則會進行佈局流程。具體可以看如下View#layout原始碼:
public void layout(int l, int t, int r, int b) {
...省略無關程式碼...
//判斷標記位是否為PFLAG_LAYOUT_REQUIRED,如果有,則對該View進行佈局
if (changed || (mPrivateFlags & PFLAG_LAYOUT_REQUIRED) == PFLAG_LAYOUT_REQUIRED) {
onLayout(changed, l, t, r, b);
//onLayout方法完成後,清除PFLAG_LAYOUT_REQUIRED標記位
mPrivateFlags &= ~PFLAG_LAYOUT_REQUIRED;
ListenerInfo li = mListenerInfo;
if (li != null && li.mOnLayoutChangeListeners != null) {
ArrayList<OnLayoutChangeListener> listenersCopy =
(ArrayList<OnLayoutChangeListener>)li.mOnLayoutChangeListeners.clone();
int numListeners = listenersCopy.size();
for (int i = 0; i < numListeners; ++i) {
listenersCopy.get(i).onLayoutChange(this, l, t, r, b, oldL, oldT, oldR, oldB);
}
}
}
//最後清除PFLAG_FORCE_LAYOUT標記位
mPrivateFlags &= ~PFLAG_FORCE_LAYOUT;
mPrivateFlags3 |= PFLAG3_IS_LAID_OUT;
}
複製程式碼
從上面的分析可以看出當子View呼叫requestLayout方法,會標記當前View及父容器,同時逐層向上提交,直到ViewRootImpl處理該事件,ViewRootImpl會呼叫三大流程,從measure開始,對於每一個含有標記位的view及其子View都會進行測量、佈局、繪製。
另外我也在這裡簡單介紹下當呼叫invalidate和postInvalidate時,View的內部呼叫邏輯。 直接上結論: 當子View呼叫了invalidate方法後,會為該View新增一個標記位,同時不斷向父容器請求重新整理,父容器通過計算得出自身需要重繪的區域,直到傳遞到ViewRootImpl中,最終觸發performTraversals方法,進行開始View樹重繪流程(只繪製需要重繪的檢視)。
回到,XimageView中,我們知道,當呼叫requestLayout時會呼叫 onMeasure和onLayout以及onDraw函式,因為比例發生變化,我們需要重新測量,方法如下:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
// 優先順序從大到小:
// mIsWidthFitDrawableSizeRatio mIsHeightFitDrawableSizeRatio
// mWidthRatio mHeightRatio
if (mDrawableSizeRatio > 0) {
// 根據前景圖寬高比例來測量view的大小
if (mIsWidthFitDrawableSizeRatio) {
mWidthRatio = mDrawableSizeRatio;
} else if (mIsHeightFitDrawableSizeRatio) {
mHeightRatio = 1 / mDrawableSizeRatio;
}
}
if (mHeightRatio > 0 && mWidthRatio > 0) {
throw new RuntimeException("高度和寬度不能同時設定百分比!!");
}
if (mWidthRatio > 0) { // 高度已知,根據比例,設定寬度
int height = 0;
if (mDesiredHeight > 0) {
height = mDesiredHeight;
} else {
height = MeasureSpec.getSize(heightMeasureSpec);
}
int width = (int) (height * mWidthRatio);
if (mIsWidthFitDrawableSizeRatio && mMaxWidthWhenWidthFixDrawable > 0
&& width > mMaxWidthWhenWidthFixDrawable) { // 限制最大寬度
width = mMaxWidthWhenWidthFixDrawable;
height = (int) (width / mWidthRatio);
}
super.onMeasure(MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY));
} else if (mHeightRatio > 0) { // 寬度已知,根據比例,設定高度
int width = 0;
if (mDesiredWidth > 0) {
width = mDesiredWidth;
} else {
width = MeasureSpec.getSize(widthMeasureSpec);
}
int height = (int) (width * mHeightRatio);
if (mIsHeightFitDrawableSizeRatio && mMaxHeightWhenHeightFixDrawable > 0
&& height > mMaxHeightWhenHeightFixDrawable) { // 限制最大高度
height = mMaxHeightWhenHeightFixDrawable;
width = (int) (height / mHeightRatio);
}
super.onMeasure(MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY));
} else if (mDesiredHeight > 0 && mDesiredWidth > 0) { // 當沒有設定其他屬性時,width和height必須同時設定才生效
int width = mDesiredWidth;
int height = mDesiredHeight;
super.onMeasure(MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY));
} else { // 系統預設測量
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
}
複製程式碼
程式碼就是根據當前配置的比例對widthMeasureSpec和heightMeasureSpec重新賦值。
因為我們並沒有改變ImageView的佈局和繪製,所以當重新測量後,仍會按系統預設的方式重新佈局和繪製。