前言
在学习本文之前,可以先了解下android LayoutInflater源码分析以及换肤框架实现原理,这里通过LayoutInflater解析自定义属性,实现对布局中的View进行平移操作。
最终效果
实现思路
- 首先自定义平移属性,如支持X轴方向进出平移:
translationXIn
、translationXOut
、Y轴方向进出平移translationYIn
、translationYOut
- 由于我们在布局文件中使用自定义的属性,那我们对自定义的属性进行解析,那怎么才能解析我们自定义的属性呢?那就需要了解
LayoutInflater源码
,这里不再赘述,我们通过实现LayoutInflater.Factory2
接口,重写onCreateView
方法进行自定义属性解析,并把结果存放到View的自定义Tag中
【避免与其他tag冲突】 - 自定义属性解析完成后,我们便可以监听ViewPager滚动时根据设置的属性进行平移效果以完成View的视差平移;
相关代码
- 自定义属性以及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>
- 自定义Fragment实现自定义属性解析
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);
}
}
}
- 自定义Viewpager,监听滚动以实现对布局中的view进行平移
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的加载工作,对以后的工作很有帮助。
结语
如果以上文章对您有一点点帮助,希望您不要吝啬的点个赞加个关注,您每一次小小的举动都是我坚持写作的不懈动力!ღ( ´・ᴗ・` )