搞事情,自定義 LayoutInflate 實現酷炫引導頁

diamond_lin發表於2017-08-31

今天,我們來搞點事情,自定義一個 LayoutInflate,搞點有意思的東西,實現一個酷炫的動畫。
首先,在自定義 LayoutInflate 之前,我們要先分析一下 LayoutInflate 的原始碼,瞭解了原始碼的實現方式,才能定製嘛~~~~
好了,怕你們無聊跑了,先放效果圖出來鎮貼

仿小紅書.gif
仿小紅書.gif

好了,效果看完了,

那就先從LayoutInflate的原始碼開始吧。

##LayoutInflate
先看看官方文件吧~我英語不好,就不幫大家一句一句翻譯了,反正大家也都知道這個類是幹嘛的。

LayoutInflate.png
LayoutInflate.png

還是提取一下關鍵資訊吧。
1.LayoutInflate 可以將 xml 檔案解析成 View 物件。獲取方式有兩種getLayoutInflater()和getSystemService(Class)。

2.如果要建立一個新的 LayoutInflate去解析你自己的 xml,可以使用 cloneInContext,然後呼叫 setFactor()。

好了,我們先來回顧一下平時我們是怎麼把 xml 轉換成 View 的吧。

  • setContentView()

我們給 Activity 設定 佈局 xml 都是呼叫這個方法,現在我們就來看看這個方法到底幹了什麼事。

public void setContentView(@LayoutRes int layoutResID) {
  getWindow().setContentView(layoutResID);
  initWindowDecorActionBar();
}
-----以上是 Activity 的方法,呼叫了 Window 的 steContentView
----手機上的 window 都是 PhoneWindow,就不饒彎了,直接看 PhoneWindow
----的setContentView方法。
public void setContentView(int layoutResID) {
    // Note: FEATURE_CONTENT_TRANSITIONS may be set in the process of installing the window
    // decor, when theme attributes and the like are crystalized. Do not check the feature
    // before this happens.
    if (mContentParent == null) {
        installDecor();
    } else if (!hasFeature(FEATURE_CONTENT_TRANSITIONS)) {
        mContentParent.removeAllViews();
    }

    if (hasFeature(FEATURE_CONTENT_TRANSITIONS)) {
        final Scene newScene = Scene.getSceneForLayout(mContentParent, layoutResID,
                getContext());
        transitionTo(newScene);
    } else {
        mLayoutInflater.inflate(layoutResID, mContentParent);
    }
    mContentParent.requestApplyInsets();
    final Callback cb = getCallback();
    if (cb != null && !isDestroyed()) {
        cb.onContentChanged();
    }
}
----在構造方法裡面找到了mLayoutInflater 的賦值
public PhoneWindow(Context context) {
    super(context);
    mLayoutInflater = LayoutInflater.from(context);
}複製程式碼
  • View.inflate()

同樣是呼叫了LayoutInflate.inflate()方法

public static View inflate(Context context, @LayoutRes int resource, ViewGroup root) {
     LayoutInflater factory = LayoutInflater.from(context);
     return factory.inflate(resource, root);
 }複製程式碼
  • LayoutInflate.from(context).inflate()
    同上

我們專案中所有的 Xml 轉 View 都離不開這三個方法吧,這三個方法最終呼叫的都還是 LayoutInflate 的 inflate 方法。

我們再來看看怎麼獲取到 LayoutInflate 的例項。
上面三個xml 解析成 view 的方法都是用LayoutInflate.from(context)來獲取 LayoutInflate 例項的。

 public static LayoutInflater from(Context context) {
    LayoutInflater LayoutInflater =
            (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
    if (LayoutInflater == null) {
        throw new AssertionError("LayoutInflater not found.");
    }
    return LayoutInflater;
}複製程式碼

看到這個程式碼有木有覺得很眼熟啊,我們的 ActivityService、WindowService、NotificationService等等各種 Service 是不是都這樣獲取的。而我們都知道這些系統服務都是單例的,並且在應用啟動的時候系統為其初始化的。好了,撤遠了~~

回過頭來,我們繼續看 LayoutInflate 原始碼。

  • inflate(@LayoutRes int resource, @Nullable ViewGroup root)
    這個方法就是將xml 檔案轉換成 View 的方法,我們專案中所有的 xml 解析呼叫的都是這個方法。第一個引數是 xml 資源 id,第二個方法是解析後的 View 是否要新增到 root view裡面去。

通過 Resources 獲取 xml 解析器XmlResourceParser。

public View inflate(@LayoutRes int resource, @Nullable ViewGroup root, boolean attachToRoot) {
    final Resources res = getContext().getResources();
    if (DEBUG) {
        Log.d(TAG, "INFLATING from resource: \"" + res.getResourceName(resource) + "\" ("
                + Integer.toHexString(resource) + ")");
    }

    final XmlResourceParser parser = res.getLayout(resource);
    try {
        return inflate(parser, root, attachToRoot);
    } finally {
        parser.close();
    }
}複製程式碼

XmlResourceParser解析 xml,並且返回 view

 public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
    synchronized (mConstructorArgs) {
      //寫入跟蹤資訊,用於 Debug 相關,先不關心這個
        Trace.traceBegin(Trace.TRACE_TAG_VIEW, "inflate");

        final Context inflaterContext = mContext;
        //用於讀取 xml 節點
        final AttributeSet attrs = Xml.asAttributeSet(parser);
        Context lastContext = (Context) mConstructorArgs[0];
        mConstructorArgs[0] = inflaterContext;
        View result = root;

        try {
            // Look for the root node.
            int type;
            //空資訊直接跳過
            while ((type = parser.next()) != XmlPullParser.START_TAG &&
                    type != XmlPullParser.END_DOCUMENT) {
                // Empty
            }
            //防錯判斷
            if (type != XmlPullParser.START_TAG) {
                throw new InflateException(parser.getPositionDescription()
                        + ": No start tag found!");
            }
            //獲取類名,比如說 TextView
            final String name = parser.getName();

            if (DEBUG) {
                System.out.println("**************************");
                System.out.println("Creating root view: "
                        + name);
                System.out.println("**************************");
            }
            //如果標籤是merge
            if (TAG_MERGE.equals(name)) {
                if (root == null || !attachToRoot) {
                //merge作為頂級節點的時候必須新增的 rootview
                    throw new InflateException("<merge /> can be used only with a valid "
                            + "ViewGroup root and attachToRoot=true");
                }
   //遞迴方法去掉不必要的節點,為什麼 merge 可以優化佈局
                rInflate(parser, root, inflaterContext, attrs, false);
            } else {
                // Temp 是根節點
                final View temp = createViewFromTag(root, name, inflaterContext, attrs);

                ViewGroup.LayoutParams params = null;
   //如果不新增到 rootView 切 rootView 不等於空,則生成 LayoutParams
                if (root != null) {
                    if (DEBUG) {
                        System.out.println("Creating params from root: " +
                                root);
                    }
                    // Create layout params that match root, if supplied
                    params = root.generateLayoutParams(attrs);
                    if (!attachToRoot) {
                        // Set the layout params for temp if we are not
                        // attaching. (If we are, we use addView, below)
                        temp.setLayoutParams(params);
                    }
                }

                if (DEBUG) {
                    System.out.println("-----> start inflating children");
                }

                // 解析子節點
                rInflateChildren(parser, temp, attrs, true);

                if (DEBUG) {
                    System.out.println("-----> done inflating children");
                }

                // 如果要新增到 rootview。。
                // to root. Do that now.
                if (root != null && attachToRoot) {
                    root.addView(temp, params);
                }

                // Decide whether to return the root that was passed in or the
                // top view found in xml.
                if (root == null || !attachToRoot) {
                    result = temp;
                }
            }

        } catch (XmlPullParserException e) {
            InflateException ex = new InflateException(e.getMessage());
            ex.initCause(e);
            throw ex;
        } catch (Exception e) {
            InflateException ex = new InflateException(
                    parser.getPositionDescription()
                            + ": " + e.getMessage());
            ex.initCause(e);
            throw ex;
        } finally {
            // Don't retain static reference on context.
            mConstructorArgs[0] = lastContext;
            mConstructorArgs[1] = null;
        }

        Trace.traceEnd(Trace.TRACE_TAG_VIEW);
        //返回解析結果
        return result;
    }
  }複製程式碼

在這個方法中,判斷了是否使用 merge 優化佈局,然後通過createViewFromTag解析的頂級 xml 節點的 view,並且處理了是否新增解析的佈局到 rootView。呼叫rInflateChildren方法去解析子 View 並且新增到頂級節點 temp 裡面。最後返回解析結果。

我們先來看看 createViewFromTag

 View createViewFromTag(View parent, String name, Context context, AttributeSet attrs,
        boolean ignoreThemeAttr) {
    //獲取名稱空間
    if (name.equals("view")) {
        name = attrs.getAttributeValue(null, "class");
    }
    // 給 view 設定主題。現在知道為什麼colorPrimary等 theme 屬性會影響控制元件顏色了吧
    if (!ignoreThemeAttr) {
        final TypedArray ta = context.obtainStyledAttributes(attrs, ATTRS_THEME);
        final int themeResId = ta.getResourceId(0, 0);
        if (themeResId != 0) {
            context = new ContextThemeWrapper(context, themeResId);
        }
        ta.recycle();
    }
 //讓 view 閃爍,可以參考http://blog.csdn.net/qq_22644219/article/details/69367150
    if (name.equals(TAG_1995)) {
        // Let's party like it's 1995!
        return new BlinkLayout(context, attrs);
    }

    try {
        View view;
        優先呼叫了mFactory2的 oncreateView 方法,建立了 temp View
        if (mFactory2 != null) {
            view = mFactory2.onCreateView(parent, name, context, attrs);
        } else if (mFactory != null) {
            view = mFactory.onCreateView(name, context, attrs);
        } else {
            view = null;
        }

        if (view == null && mPrivateFactory != null) {
            view = mPrivateFactory.onCreateView(parent, name, context, attrs);
        }

        if (view == null) {
            final Object lastContext = mConstructorArgs[0];
            mConstructorArgs[0] = context;
            try {
                if (-1 == name.indexOf('.')) {
                    view = onCreateView(parent, name, attrs);
                } else {
                    view = createView(name, null, attrs);
                }
            } finally {
                mConstructorArgs[0] = lastContext;
            }
        }

        return view;
    } catch (InflateException e) {
        throw e;

    } catch (ClassNotFoundException e) {
        final InflateException ie = new InflateException(attrs.getPositionDescription()
                + ": Error inflating class " + name);
        ie.initCause(e);
        throw ie;

    } catch (Exception e) {
        final InflateException ie = new InflateException(attrs.getPositionDescription()
                + ": Error inflating class " + name);
        ie.initCause(e);
        throw ie;
    }
}複製程式碼

這裡我們可以知道,mFactor或者 mFactor 不為 null,則呼叫mFactor來建立 View,如果mFactor為 null 或者mFactor建立是失敗,則最終呼叫LayoutInflate 的createView方法 來建立 View 的,它傳入了 view 的 parent、name、context、 attrs。

接下來繼續去看子 View 的解析rInflateChildren

 void rInflate(XmlPullParser parser, View parent, Context context,
        AttributeSet attrs, boolean finishInflate) throws XmlPullParserException, IOException {
 //獲取佈局層級
    final int depth = parser.getDepth();
    int type;
    //沒看懂沒事,我們不是來糾結 xml 解析的
    while (((type = parser.next()) != XmlPullParser.END_TAG ||
            parser.getDepth() > depth) && type != XmlPullParser.END_DOCUMENT) {

        if (type != XmlPullParser.START_TAG) {
            continue;
        }

        final String name = parser.getName();
        //requestFocus標籤,http://blog.csdn.net/ouyang_peng/article/details/46957281
        if (TAG_REQUEST_FOCUS.equals(name)) {
            parseRequestFocus(parser, parent);
        } else if (TAG_TAG.equals(name)) {
        //tag標籤,只能用於 api21以上,給父view 設定一個 tag
            parseViewTag(parser, parent, attrs);
        } else if (TAG_INCLUDE.equals(name)) {
        //include 節點
            if (parser.getDepth() == 0) {
                throw new InflateException("<include /> cannot be the root element");
            }
            parseInclude(parser, context, parent, attrs);
        } else if (TAG_MERGE.equals(name)) {
        //merge 節點
            throw new InflateException("<merge /> must be the root element");
        } else {
           //走了剛剛的那個方法,建立 view 設定 LayoutParams
            final View view = createViewFromTag(parent, name, context, attrs);
            final ViewGroup viewGroup = (ViewGroup) parent;
            final ViewGroup.LayoutParams params = viewGroup.generateLayoutParams(attrs);
            rInflateChildren(parser, view, attrs, true);
            //新增到付 view
            viewGroup.addView(view, params);
        }
    }

    if (finishInflate) {
        parent.onFinishInflate();
    }
}複製程式碼

我們來整理一下思路吧,呼叫步驟
1.LayoutInflater 的靜態方法 form 獲取LayoutInflater實力
2.inflate解析 xml 資源
3.inflate 呼叫createViewFromTag建立了頂級view
4.inflate 呼叫rInflateChildren 建立所有子 view
5.rInflateChildren遞迴呼叫rInflate建立所有子 view。
6.rInflate通過呼叫createViewFromTag真正建立一個 view。
7.createViewFromTag優先使用 mFactory2、mFactory、mPrivateFactory來建立 View,如果建立失敗,則最終呼叫createView方法來建立。建立的過程中用了parent,name,context,attrs等引數,然後運用反射的方法,建立出 View,

因此,我們所有的 View 的構造方法都是被 LayoutInflate 的Factory呼叫建立出來的。
如果要自定義 LayoutInflate 解析,只需要給呼叫LayoutInflate的 setFactory設定我們自定義的 Factory 即可。
但是問題來了,LayoutInflate是系統服務,而且是單例,我們直接呼叫LayoutInflate的 setFactory 方法,會影響後期所有 view 的建立。

所以我們需要用到LayoutInflate的cloneInContext方法clone一個新的 LayoutInflate,然後再設定自己的 Factory。至於LayoutInflate是一個抽象類,cloneInContext是一個抽象方法,我們根本不用關心,因為我們直接用系統建立好的LayoutInflate即可。

好了,LayoutInflate的原始碼分析完了,接下來我們來分析動畫了。

##動畫分析

原始碼看了很久,我們再來重新看一遍動畫吧

仿小紅書.gif
仿小紅書.gif

1.翻頁
2.翻頁的時候天上的雲,地上的建築物移動速度和翻頁速度不一樣
3.不同的背景物移動速度不一樣,最後一頁背景物上下擴散
4.翻頁的過程中,人一直在走路
5.最後一頁人要消失。

解決方案:

1.ViewPager
2.給 viewPage設定PageChangeListener,在滾動的時候給各種 背景物體設定setTranslation。
3.不同的背景物設定不同的setTranslation係數。
4.人物走路用幀動畫即可,在viewPage滑動處於SCROLL_STATE_DRAGGING狀態的時候開啟幀動畫。
5.這個簡單,監聽onPageSelected,然後再設定人為 View.GONE即可。

解決方案的問題:
粗略數了一下,6個頁面大概有50個左右的背景物。如果要一個一個去獲取 id,然後再根據不同的 id,設定不同的滑動速度滑動方向,可能你會瘋掉。

因此,我們需要想一個辦法,去解決這個問題。可能有的童鞋會說,我寫一個自定義 View,設定滑動速度係數屬性就行了呀。這個方法可以實現,but,你還是需要一個一個去 findViewbyid。

那麼,我們是不是可以給 xml 新增自定義標籤,然後自定義解析。比如說,天上的雲,滑進來的阻尼係數是0.4,滑出去的阻尼係數是0.6,只需要在 xml 裡面設定好這兩個引數,然後我們再在合適的時使用這兩個引數即可啊。

##自定義LayoutInflater.Factory
咦,怎麼變成自定義LayoutInflater.Factory了,哈哈哈,還記得剛剛LayoutInflater的原始碼分析麼,View 的建立全部在createViewFromTag裡面,而createViewFromTag優先使用 Factory 來 建立。然後我們來看看Factory到底是幹嘛的。

Hook you can supply that is called when inflating from a LayoutInflater.
You can use this to customize the tag names available in your XML layout files.

  • 當LayoutInflater在解析佈局的時候會被呼叫
  • 可以用來讀取 xml 中的自定義標籤。

這下迷惑都解開了吧,啊哈哈哈哈~~
現在,我們就來定義這個 Factory
思路很簡單。
1.繼承LayoutInflater.Factory2
2.實現抽象方法onCreateView
3.在onCreateView裡面使用 LayoutInflate 的 createView方法建立View
4.建立成功之後,讀取 view 的 attrs 屬性,作為 tag 保持到 viewTag。

關鍵程式碼如下:

 @Override
public View onCreateView(String name, Context context, AttributeSet attrs) {
 //建立一個 View
    View view = createViewOrFailQuietly(name, context, attrs);

    //例項化完成
    if (view != null) {
        //獲取自定義屬性,通過標籤關聯到檢視上
        setViewTag(view, context, attrs);
        //所有帶有自定義屬性的 View 儲存起來,供動畫切換的時候呼叫
        mParallaxView.getParallaxViews().add(view);
    }
    return view;
}複製程式碼

建立 view 的方法,這裡注意一下,xml 標籤裡面系統的 view只有類名,自定義 view 是全路徑。如:,而可以省略路徑的 View 又分為 "android.widget."和"android.view."包下,所以對於只寫縮寫的 view,需要遍歷這兩個路徑。

 private View createViewOrFailQuietly(String name, Context context,
                                     AttributeSet attrs) {
    //1.自定義控制元件標籤名稱帶點,所以建立時不需要字首
    if (name.contains(".")) {
        createViewOrFailQuietly(name, null, context, attrs);
    }
    //2.系統檢視需要加上字首
    for (String prefix : sClassPrefix) {
        View view = createViewOrFailQuietly(name, prefix, context, attrs);
        if (view != null) {
            return view;
        }
    }
    return null;
}
private View createViewOrFailQuietly(String name, String prefix, Context context,
                                     AttributeSet attrs) {
    try {
        //通過系統的inflater建立檢視,讀取系統的屬性
        return inflater.createView(name, prefix, attrs);
    } catch (Exception e) {
        return null;
    }
}複製程式碼

讀取 attrs 裡面的屬性,給含有特點 attrs 屬性的 view設定 tag 並儲存起來。

 private void setViewTag(View view, Context context, AttributeSet attrs) {
    //所有自定義的屬性
    TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.AnimationView);
    if (a != null && a.length() > 0) {
        //獲取自定義屬性的值
        ParallaxViewTag tag = new ParallaxViewTag();
        tag.xIn = a.getFloat(R.styleable.AnimationView_x_in, 0f);
        tag.xOut = a.getFloat(R.styleable.AnimationView_x_out, 0f);
        tag.yIn = a.getFloat(R.styleable.AnimationView_y_in, 0f);
        tag.yOut = a.getFloat(R.styleable.AnimationView_y_in, 0f);

        //index
        view.setTag(view.getId(), tag);
        a.recycle();
    }

}複製程式碼

好了,我們自定義LayoutInflater.Factory已經結束了,so,我們可以直接呼叫 LayoutInflate.cloneInContext(context)獲取一個新的 LayoutInflate,然後再setFactor(customFactor)就可以了。程式碼如下:

 @Override
public View onCreateView(LayoutInflater original, ViewGroup container,
                         Bundle savedInstanceState) {
    Bundle args = getArguments();
    int layoutId = args.getInt("layoutId");
    LayoutInflater layoutInflater = original.cloneInContext(getActivity());
    layoutInflater.setFactory(new ParallaxFactory(layoutInflater, this));
    return layoutInflater.inflate(layoutId, null);
}複製程式碼

接下來的程式碼就不寫了吧,就是監聽 ViewPager 的滑動事件,獲取當前滑出滑進頁面的自定義了 attrs 屬性的 View 列表,然後再根據滑出螢幕的比例*屬性引數做 view 的 TranslationY/TranslationX 操作。
這裡我貼一下程式碼倉庫地址吧,有興趣的小夥伴可以把程式碼跑起來看一下

github傳送門

看起來好像並沒有什麼卵用,就是秀了一波騷操作。寫一個自定義 view,繼承 ImageView,設定幾個自定義 attrs 屬性,再在構造方法裡面把屬性讀出來儲存到類變數,對外提供讀取方法,然後同樣監聽 viewpager 的滑動就行了。

哈哈哈哈~~分享這篇文章的最終目的不是為了實現這個動畫,就是想看一下 LayoutInflate 的原始碼,瞭解一下 xml 檔案是怎麼解析成 view的過程。。。。

##已知 bug:

  • v4的版本升級到19.1.0之後動畫會失效
  • 引入appcompat包會報 xml 解析錯誤。

版本升級引起的 bug,有時間我去找找這兩個 bug 的原因,找到之後我會在這裡更新。


本次效果來源於動腦學院視訊課程,很不錯的一套課程,感興趣的小夥伴可以去學學,適用於有一定基礎的 android 程式設計師,進階高階很有效果哦。騰訊課堂有免費的公開課,或者去動腦學院學習,官網的課程好像是收費的,當然費用對程式設計師來說不算高,付不起課程費用的大學生可以去騰訊課堂學習,或者某寶。。。。。。。。

相關文章