在学习本文之前,可以先了解下android LayoutInflater源码分析以及换肤框架实现原理,这里通过LayoutInflater解析自定义属性,实现对布局中的View进行平移操作。

translationXIn、translationXOut、Y轴方向进出平移translationYIn、translationYOutLayoutInflater源码,这里不再赘述,我们通过实现LayoutInflater.Factory2接口,重写onCreateView方法进行自定义属性解析,并把结果存放到View的自定义Tag中【避免与其他tag冲突】attrs.xml <!--自定义视觉属性-->
<!--X方向的位移-->
<attr name="translationXIn" format="float"/>
<attr name="translationXOut" format="float"/>
<!--Y方向的位移-->
<attr name="translationYIn" format="float"/>
<attr name="translationYOut" format="float"/>
attrs.xml <item name="parallax_tag" type="id"/>
fragment_page.xml<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/rootFirstPage"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:background="@android:color/holo_orange_dark">
<ImageView
android:id="@+id/ivFirstImage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@mipmap/s_0_1"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintHeight_percent="0.35"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintWidth_percent="0.5"
app:translationXIn="0.4"
app:translationXOut="0.4"
app:translationYIn="0.4"
app:translationYOut="0.4" />
<ImageView
android:id="@+id/ivSecondImage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_marginTop="100dp"
android:layout_marginEnd="50dp"
android:src="@mipmap/s_0_2"
app:layout_constraintHeight_percent="0.1"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintWidth_percent="0.12"
app:translationXIn="0.12"
app:translationXOut="0.12"
app:translationYIn="0.82"
app:translationYOut="0.82" />
<ImageView
android:id="@+id/ivFourthImage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="60dp"
android:layout_marginTop="120dp"
android:src="@mipmap/s_0_4"
app:layout_constraintHeight_percent="0.15"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintWidth_percent="0.12"
app:translationXIn="0.2"
app:translationXOut="0.2" />
</androidx.constraintlayout.widget.ConstraintLayout>
package com.crystal.view.parallax
import android.content.Context
import android.os.Build
import android.os.Bundle
import android.util.AttributeSet
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.ViewParent
import androidx.core.view.LayoutInflaterCompat
import androidx.core.view.ViewCompat
import androidx.fragment.app.Fragment
import com.crystal.view.R
import org.xmlpull.v1.XmlPullParser
/**
* 支持视差动画的自定义Fragment
* on 2022/11/14
*/
class ParallaxFragment : Fragment(), LayoutInflater.Factory2 {
//用于fragment数据传递
companion object {
val LAYOUT_ID_KEY = "LAYOUT_ID_KEY"
}
//用于存放所有需要平移的View
private val parallaxViews = arrayListOf<View>()
//自定义的平移属性
private val parallaxAttrs = intArrayOf(
R.attr.translationXIn,
R.attr.translationXOut,
R.attr.translationYIn,
R.attr.translationYOut
)
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val layoutId = arguments?.getInt(LAYOUT_ID_KEY)
//由于LayoutInflater是单例模式,这里我们首先需要构建一个新的inflater,如果直接传inflater,则代表所有的view的创建都会由此fragment去完成
val newInflater = inflater.cloneInContext(activity)
//设置走自己的onCreateView方法
LayoutInflaterCompat.setFactory2(newInflater, this)
return newInflater.inflate(layoutId ?: 0, container, false)
}
override fun onCreateView(
parent: View,
name: String,
context: Context,
attrs: AttributeSet
): View {
Log.e("onCreateView", "our self onCreateView")
val view = createView(parent, name, context, attrs)
if (view != null) {
analysisAttrs(view, context, attrs)
}
return view
}
/**
* 解析自定义属性
*/
private fun analysisAttrs(view: View, context: Context, attrs: AttributeSet) {
val array = context.obtainStyledAttributes(attrs, parallaxAttrs)
if (array != null && array.indexCount != 0) {
val tag = ParallaxTag()
for (i in 0 until array.indexCount) {
when (val attr = array.getIndex(i)) {
0 -> {
tag.translationXIn = array.getFloat(attr, 0f)
}
1 -> {
tag.translationXOut = array.getFloat(attr, 0f)
}
2 -> {
tag.translationYIn = array.getFloat(attr, 0f)
}
3 -> {
tag.translationYOut = array.getFloat(attr, 0f)
}
}
}
//给view设置一个自定义的tag
view.setTag(R.id.parallax_tag, tag)
parallaxViews.add(view)
array.recycle()
}
}
override fun onCreateView(name: String, context: Context, attrs: AttributeSet): View {
Log.e("onCreateView", "our self onCreateView")
val view = createView(null, name, context, attrs)
if (view != null) {
analysisAttrs(view, context, attrs)
}
return view
}
private val IS_PRE_LOLLIPOP = Build.VERSION.SDK_INT < 21
private fun createView(
parent: View?,
name: String,
context: Context,
attrs: AttributeSet
): View {
//参考AppCompatDelegateImpl实现
var inheritContext = false
if (IS_PRE_LOLLIPOP) {
inheritContext =
if (attrs is XmlPullParser // If we have a XmlPullParser, we can detect where we are in the layout
) (attrs as XmlPullParser).depth > 1 // Otherwise we have to use the old heuristic
else shouldInheritContext((parent as ViewParent?)!!)
}
val parallaxCompatViewInflater = ParallaxCompatViewInflater()
return parallaxCompatViewInflater.createView(
parent, name, context, attrs, inheritContext,
IS_PRE_LOLLIPOP, /* Only read android:theme pre-L (L+ handles this anyway) */
true, /* Read read app:theme as a fallback at all times for legacy reasons */
false /* Only tint wrap the context if enabled */
)
}
//参考AppCompatDelegateImpl实现
private fun shouldInheritContext(parent: ViewParent): Boolean {
var parent: ViewParent? = parent
?: // The initial parent is null so just return false
return false
val windowDecor: View = requireActivity().window.decorView
while (true) {
if (parent == null) {
// Bingo. We've hit a view which has a null parent before being terminated from
// the loop. This is (most probably) because it's the root view in an inflation
// call, therefore we should inherit. This works as the inflated layout is only
// added to the hierarchy at the end of the inflate() call.
return true
} else if (parent === windowDecor || parent !is View
|| ViewCompat.isAttachedToWindow((parent as View?)!!)
) {
// We have either hit the window's decor view, a parent which isn't a View
// (i.e. ViewRootImpl), or an attached view, so we know that the original parent
// is currently added to the view hierarchy. This means that it has not be
// inflated in the current inflate() call and we should not inherit the context.
return false
}
parent = parent.getParent()
}
}
fun getParallaxViews(): ArrayList<View> {
return parallaxViews
}
}
其中ParallaxTag为数据类
data class ParallaxTag(
var translationXIn: Float = 0f,
var translationXOut: Float = 0f,
var translationYIn: Float = 0f,
var translationYOut: Float = 0f
) {
override fun toString(): String {
return "translationXIn->$translationXIn translationXOut->$translationXOut translationYIn->$translationYIn translationYOut->$translationYOut";
}
}
其中ParallaxCompatViewInflater类参考系统中的AppCompatViewInflater类,以调用createView方法,对应源码如下:
package com.crystal.view.parallax;
import android.content.Context;
import android.content.ContextWrapper;
import android.content.res.TypedArray;
import android.os.Build;
import android.util.AttributeSet;
import android.util.Log;
import android.view.InflateException;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.R;
import androidx.appcompat.view.ContextThemeWrapper;
import androidx.appcompat.widget.AppCompatAutoCompleteTextView;
import androidx.appcompat.widget.AppCompatButton;
import androidx.appcompat.widget.AppCompatCheckBox;
import androidx.appcompat.widget.AppCompatCheckedTextView;
import androidx.appcompat.widget.AppCompatEditText;
import androidx.appcompat.widget.AppCompatImageButton;
import androidx.appcompat.widget.AppCompatImageView;
import androidx.appcompat.widget.AppCompatMultiAutoCompleteTextView;
import androidx.appcompat.widget.AppCompatRadioButton;
import androidx.appcompat.widget.AppCompatRatingBar;
import androidx.appcompat.widget.AppCompatSeekBar;
import androidx.appcompat.widget.AppCompatSpinner;
import androidx.appcompat.widget.AppCompatTextView;
import androidx.appcompat.widget.AppCompatToggleButton;
import androidx.collection.SimpleArrayMap;
import androidx.core.view.ViewCompat;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
/**
* 参考AppCompatViewInflater类,以调用createView方法
* on 2022/11/14
*/
public class ParallaxCompatViewInflater {
private static final Class<?>[] sConstructorSignature = new Class<?>[]{
Context.class, AttributeSet.class};
private static final int[] sOnClickAttrs = new int[]{android.R.attr.onClick};
private static final String[] sClassPrefixList = {
"android.widget.",
"android.view.",
"android.webkit."
};
private static final String LOG_TAG = "AppCompatViewInflater";
private static final SimpleArrayMap<String, Constructor<? extends View>> sConstructorMap =
new SimpleArrayMap<>();
private final Object[] mConstructorArgs = new Object[2];
final View createView(View parent, final String name, @NonNull Context context,
@NonNull AttributeSet attrs, boolean inheritContext,
boolean readAndroidTheme, boolean readAppTheme, boolean wrapContext) {
final Context originalContext = context;
// We can emulate Lollipop's android:theme attribute propagating down the view hierarchy
// by using the parent's context
if (inheritContext && parent != null) {
context = parent.getContext();
}
if (readAndroidTheme || readAppTheme) {
// We then apply the theme on the context, if specified
context = themifyContext(context, attrs, readAndroidTheme, readAppTheme);
}
View view = null;
// We need to 'inject' our tint aware Views in place of the standard framework versions
switch (name) {
case "TextView":
view = createTextView(context, attrs);
verifyNotNull(view, name);
break;
case "ImageView":
view = createImageView(context, attrs);
verifyNotNull(view, name);
break;
case "Button":
view = createButton(context, attrs);
verifyNotNull(view, name);
break;
case "EditText":
view = createEditText(context, attrs);
verifyNotNull(view, name);
break;
case "Spinner":
view = createSpinner(context, attrs);
verifyNotNull(view, name);
break;
case "ImageButton":
view = createImageButton(context, attrs);
verifyNotNull(view, name);
break;
case "CheckBox":
view = createCheckBox(context, attrs);
verifyNotNull(view, name);
break;
case "RadioButton":
view = createRadioButton(context, attrs);
verifyNotNull(view, name);
break;
case "CheckedTextView":
view = createCheckedTextView(context, attrs);
verifyNotNull(view, name);
break;
case "AutoCompleteTextView":
view = createAutoCompleteTextView(context, attrs);
verifyNotNull(view, name);
break;
case "MultiAutoCompleteTextView":
view = createMultiAutoCompleteTextView(context, attrs);
verifyNotNull(view, name);
break;
case "RatingBar":
view = createRatingBar(context, attrs);
verifyNotNull(view, name);
break;
case "SeekBar":
view = createSeekBar(context, attrs);
verifyNotNull(view, name);
break;
case "ToggleButton":
view = createToggleButton(context, attrs);
verifyNotNull(view, name);
break;
default:
// The fallback that allows extending class to take over view inflation
// for other tags. Note that we don't check that the result is not-null.
// That allows the custom inflater path to fall back on the default one
// later in this method.
view = createView(context, name, attrs);
}
if (view == null) {
// If the original context does not equal our themed context, then we need to manually
// inflate it using the name so that android:theme takes effect.
view = createViewFromTag(context, name, attrs);
}
if (view != null) {
// If we have created a view, check its android:onClick
checkOnClickListener(view, attrs);
}
return view;
}
@NonNull
protected AppCompatTextView createTextView(Context context, AttributeSet attrs) {
return new AppCompatTextView(context, attrs);
}
@NonNull
protected AppCompatImageView createImageView(Context context, AttributeSet attrs) {
return new AppCompatImageView(context, attrs);
}
@NonNull
protected AppCompatButton createButton(Context context, AttributeSet attrs) {
return new AppCompatButton(context, attrs);
}
@NonNull
protected AppCompatEditText createEditText(Context context, AttributeSet attrs) {
return new AppCompatEditText(context, attrs);
}
@NonNull
protected AppCompatSpinner createSpinner(Context context, AttributeSet attrs) {
return new AppCompatSpinner(context, attrs);
}
@NonNull
protected AppCompatImageButton createImageButton(Context context, AttributeSet attrs) {
return new AppCompatImageButton(context, attrs);
}
@NonNull
protected AppCompatCheckBox createCheckBox(Context context, AttributeSet attrs) {
return new AppCompatCheckBox(context, attrs);
}
@NonNull
protected AppCompatRadioButton createRadioButton(Context context, AttributeSet attrs) {
return new AppCompatRadioButton(context, attrs);
}
@NonNull
protected AppCompatCheckedTextView createCheckedTextView(Context context, AttributeSet attrs) {
return new AppCompatCheckedTextView(context, attrs);
}
@NonNull
protected AppCompatAutoCompleteTextView createAutoCompleteTextView(Context context,
AttributeSet attrs) {
return new AppCompatAutoCompleteTextView(context, attrs);
}
@NonNull
protected AppCompatMultiAutoCompleteTextView createMultiAutoCompleteTextView(Context context,
AttributeSet attrs) {
return new AppCompatMultiAutoCompleteTextView(context, attrs);
}
@NonNull
protected AppCompatRatingBar createRatingBar(Context context, AttributeSet attrs) {
return new AppCompatRatingBar(context, attrs);
}
@NonNull
protected AppCompatSeekBar createSeekBar(Context context, AttributeSet attrs) {
return new AppCompatSeekBar(context, attrs);
}
@NonNull
protected AppCompatToggleButton createToggleButton(Context context, AttributeSet attrs) {
return new AppCompatToggleButton(context, attrs);
}
private void verifyNotNull(View view, String name) {
if (view == null) {
throw new IllegalStateException(this.getClass().getName()
+ " asked to inflate view for <" + name + ">, but returned null");
}
}
@Nullable
protected View createView(Context context, String name, AttributeSet attrs) {
return null;
}
private View createViewFromTag(Context context, String name, AttributeSet attrs) {
if (name.equals("view")) {
name = attrs.getAttributeValue(null, "class");
}
try {
mConstructorArgs[0] = context;
mConstructorArgs[1] = attrs;
if (-1 == name.indexOf('.')) {
for (int i = 0; i < sClassPrefixList.length; i++) {
final View view = createViewByPrefix(context, name, sClassPrefixList[i]);
if (view != null) {
return view;
}
}
return null;
} else {
return createViewByPrefix(context, name, null);
}
} catch (Exception e) {
// We do not want to catch these, lets return null and let the actual LayoutInflater
// try
return null;
} finally {
// Don't retain references on context.
mConstructorArgs[0] = null;
mConstructorArgs[1] = null;
}
}
/**
* android:onClick doesn't handle views with a ContextWrapper context. This method
* backports new framework functionality to traverse the Context wrappers to find a
* suitable target.
*/
private void checkOnClickListener(View view, AttributeSet attrs) {
final Context context = view.getContext();
if (!(context instanceof ContextWrapper) ||
(Build.VERSION.SDK_INT >= 15 && !ViewCompat.hasOnClickListeners(view))) {
// Skip our compat functionality if: the Context isn't a ContextWrapper, or
// the view doesn't have an OnClickListener (we can only rely on this on API 15+ so
// always use our compat code on older devices)
return;
}
final TypedArray a = context.obtainStyledAttributes(attrs, sOnClickAttrs);
final String handlerName = a.getString(0);
if (handlerName != null) {
view.setOnClickListener(new DeclaredOnClickListener(view, handlerName));
}
a.recycle();
}
private View createViewByPrefix(Context context, String name, String prefix)
throws ClassNotFoundException, InflateException {
Constructor<? extends View> constructor = sConstructorMap.get(name);
try {
if (constructor == null) {
// Class not found in the cache, see if it's real, and try to add it
Class<? extends View> clazz = Class.forName(
prefix != null ? (prefix + name) : name,
false,
context.getClassLoader()).asSubclass(View.class);
constructor = clazz.getConstructor(sConstructorSignature);
sConstructorMap.put(name, constructor);
}
constructor.setAccessible(true);
return constructor.newInstance(mConstructorArgs);
} catch (Exception e) {
// We do not want to catch these, lets return null and let the actual LayoutInflater
// try
return null;
}
}
/**
* Allows us to emulate the {@code android:theme} attribute for devices before L.
*/
private static Context themifyContext(Context context, AttributeSet attrs,
boolean useAndroidTheme, boolean useAppTheme) {
final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.View, 0, 0);
int themeId = 0;
if (useAndroidTheme) {
// First try reading android:theme if enabled
themeId = a.getResourceId(R.styleable.View_android_theme, 0);
}
if (useAppTheme && themeId == 0) {
// ...if that didn't work, try reading app:theme (for legacy reasons) if enabled
themeId = a.getResourceId(R.styleable.View_theme, 0);
if (themeId != 0) {
Log.i(LOG_TAG, "app:theme is now deprecated. "
+ "Please move to using android:theme instead.");
}
}
a.recycle();
if (themeId != 0 && (!(context instanceof ContextThemeWrapper)
|| ((ContextThemeWrapper) context).getThemeResId() != themeId)) {
// If the context isn't a ContextThemeWrapper, or it is but does not have
// the same theme as we need, wrap it in a new wrapper
context = new ContextThemeWrapper(context, themeId);
}
return context;
}
/**
* An implementation of OnClickListener that attempts to lazily load a
* named click handling method from a parent or ancestor context.
*/
private static class DeclaredOnClickListener implements View.OnClickListener {
private final View mHostView;
private final String mMethodName;
private Method mResolvedMethod;
private Context mResolvedContext;
public DeclaredOnClickListener(@NonNull View hostView, @NonNull String methodName) {
mHostView = hostView;
mMethodName = methodName;
}
@Override
public void onClick(@NonNull View v) {
if (mResolvedMethod == null) {
resolveMethod(mHostView.getContext());
}
try {
mResolvedMethod.invoke(mResolvedContext, v);
} catch (IllegalAccessException e) {
throw new IllegalStateException(
"Could not execute non-public method for android:onClick", e);
} catch (InvocationTargetException e) {
throw new IllegalStateException(
"Could not execute method for android:onClick", e);
}
}
private void resolveMethod(@Nullable Context context) {
while (context != null) {
try {
if (!context.isRestricted()) {
final Method method = context.getClass().getMethod(mMethodName, View.class);
if (method != null) {
mResolvedMethod = method;
mResolvedContext = context;
return;
}
}
} catch (NoSuchMethodException e) {
// Failed to find method, keep searching up the hierarchy.
}
if (context instanceof ContextWrapper) {
context = ((ContextWrapper) context).getBaseContext();
} else {
// Can't search up the hierarchy, null out and fail.
context = null;
}
}
final int id = mHostView.getId();
final String idText = id == View.NO_ID ? "" : " with id '"
+ mHostView.getContext().getResources().getResourceEntryName(id) + "'";
throw new IllegalStateException("Could not find method " + mMethodName
+ "(View) in a parent or ancestor Context for android:onClick "
+ "attribute defined on view " + mHostView.getClass() + idText);
}
}
}
package com.crystal.view.parallax
import android.content.Context
import android.os.Bundle
import android.util.AttributeSet
import android.util.Log
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentPagerAdapter
import androidx.viewpager.widget.ViewPager
import com.crystal.view.R
/**
* 自定义ViewPager,监听滚动以用于View平移
* on 2022/11/14
*/
class ParallaxViewPager : ViewPager {
private val fragments = arrayListOf<ParallaxFragment>()
constructor(context: Context) : this(context, null)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
/**
* 上一次滚动时的positionOffset 用于判断当前是左滑还是右滑
*/
private var lastPositionOffset = 0f
fun setLayout(fm: FragmentManager, layoutIds: IntArray) {
fragments.clear()
for (layoutId in layoutIds) {
val fragment = ParallaxFragment()
val bundle = Bundle()
bundle.putInt(ParallaxFragment.LAYOUT_ID_KEY, layoutId)
fragment.arguments = bundle
fragments.add(fragment)
}
//设置adapter
adapter = ParallaxPagerAdapter(fm)
addOnPageChangeListener(object : OnPageChangeListener {
override fun onPageScrolled(
position: Int, positionOffset: Float, positionOffsetPixels: Int
) {
Log.e("positionOffsetPixels","$positionOffsetPixels")
/** positionOffsetPixels 屏幕宽度 右滑变化 0 - 屏幕宽度 左滑变化 屏幕宽度 - 0
* 向左滑动 内容向右平移 当前内容出去
* 向右滑动 内容向左平移 当前内容进入
*/
val outParallaxViews = fragments[position].getParallaxViews()
for (parallaxView in outParallaxViews) {
val outParallaxViewTag = parallaxView.getTag(R.id.parallax_tag) as ParallaxTag
parallaxView.translationX =
-outParallaxViewTag.translationXOut * positionOffsetPixels
parallaxView.translationY =
-outParallaxViewTag.translationYOut * positionOffsetPixels
}
try {
val inParallaxViews = fragments[position + 1].getParallaxViews()
for (parallaxView in inParallaxViews) {
val inParallaxViewTag = parallaxView.tag as ParallaxTag
parallaxView.translationX =
inParallaxViewTag.translationXIn * (measuredWidth - positionOffsetPixels)
parallaxView.translationY =
inParallaxViewTag.translationYIn * (measuredWidth - positionOffsetPixels)
}
} catch (e: Exception) {
}
}
override fun onPageSelected(position: Int) {
}
override fun onPageScrollStateChanged(state: Int) {
}
})
}
private inner class ParallaxPagerAdapter(fm: FragmentManager) : FragmentPagerAdapter(fm) {
override fun getCount(): Int {
return fragments.size
}
override fun getItem(position: Int): Fragment {
return fragments[position]
}
}
}
val viewpager = findViewById<ParallaxViewPager>(R.id.viewpager)
viewpager.setLayout(
supportFragmentManager,
intArrayOf(
R.layout.fragment_page,
R.layout.fragment_page,
R.layout.fragment_page
)
)
通过解析自定义属性,学习了LayoutInflater是如何完成View的加载工作,对以后的工作很有帮助。
如果以上文章对您有一点点帮助,希望您不要吝啬的点个赞加个关注,您每一次小小的举动都是我坚持写作的不懈动力!ღ( ´・ᴗ・` )