1. 说明
我们这节课就来看下视差动画,其实就是根据当前滚动的位置去设置位移,视差动画一般用作引导页,比如知乎、酷狗音乐等等,那么我们这节课要做的效果就是实现——酷狗音乐引导页。效果如下图所示:
效果图就上这两张,可以看到:
1.1 滑动第一张到屏幕最左边,然后第二张就会滑动到当前位置,并且第二张图片上边的图标和文字都会移动;
1.2 当第二张图片滑动到屏幕最左边之后,然后第三张图片,也会滑动到当前位置,并且随着滑动第三张图片中的文字及图标都会移动;
这个效果就是视差动画。
2. 思路分析
最外层是ViewPager + 视差动画界面是Fragment
2.1 先把布局和Fragment创建好;
在activity_main.xml布局文件中直接引用 ParallaxViewPager对应的布局文件,里边就是一个自定义的 ParallaxViewPager,如果有需要在最后一个页面添加 "立即体验"的按钮点击跳转主页面的话,可以在该Parallax布局文件下边添加一个 "立即体验"的按钮,监听 ParallaxViewPager的滑动事件,当滑动到最后一页时直接让 "立即体验" 的button按钮显示出来,其余页面让隐藏,然后设置点击事件即可;
2.2 把所有需要移动的属性解析出来
// 存放所有的需要位移的View
private List mParallaxViews = new ArrayList<>() ;
// 存放视差动画的属性
private int[] mParallaxAttrs = new int[]{R.attr.translationXIn,R.attr.translationXOut ,
R.attr.translationYIn,R.attr.translationYOut};
@Nullable
@Override
public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
// 获取布局的id
int layoutId = getArguments().getInt(LAYOUT_ID_KEY);
// 2.2.2 把所有需要移动的属性解析出来 ,内涵段子插件式换肤有
// View创建的时候我们去解析,这里传inflater是有问题的, 单例设计模式代表所有View的创建都是该 Fragment去创建的
// 克隆一个inflater出来
inflater = inflater.cloneInContext(getActivity()) ;
LayoutInflaterCompat.setFactory(inflater , this);
return inflater.inflate(layoutId , container , false);
}
/**
* 这个方法是实现LayoutInflaterFactory接口后重写的该方法
* @param parent
* @param name
* @param context
* @param attrs
* @return
*/
@Override
public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
// 所有的View都会在这里创建
// 拦截到View的创建 获取View之后要去解析
// 1.创建View
// If the Factory didn't handle it, let our createView() method try
View view = createView(parent , name ,context , attrs) ;
// 2.1 一个 activity的布局肯定对应多个这样的 SkinView
if (view != null){
// 解析所有的 我们关注的属性
analysisAttrs(view , context , attrs) ;
}
return view;
}
/**
* 解析所有的 我们关注的属性
* @param view
* @param context
* @param attrs
*/
private void analysisAttrs(View view, Context context, AttributeSet attrs) {
TypedArray array = context.obtainStyledAttributes(attrs, mParallaxAttrs);
if (array != null && array.getIndexCount() != 0){
int n = array.getIndexCount();
ParallaxTag tag = new ParallaxTag();
for (int i = 0; i < n; i++) {
int attr = array.getIndex(i);
switch (attr){
case 0:
tag.translationXIn = array.getFloat(attr , 0f) ;
break;
case 1:
tag.translationXOut = array.getFloat(attr , 0f) ;
break;
case 2:
tag.translationYIn = array.getFloat(attr , 0f) ;
break;
case 3:
tag.translationYOut = array.getFloat(attr , 0f) ;
break;
}
}
// 自定义属性怎么存? 需要一一绑定,在View上边设置tag
view.setTag(R.id.parallax_tag , tag);
mParallaxViews.add(view) ;
}
array.recycle();
}
2.3 监听滑动改变位移;
public void setLayoutId(FragmentManager fm ,int[] layoutIds){
mFragments.clear();
for (int layoutId : layoutIds) {
ParallaxFragment fragment = new ParallaxFragment() ;
Bundle bundle = new Bundle() ;
bundle.putInt(ParallaxFragment.LAYOUT_ID_KEY , layoutId);
fragment.setArguments(bundle);
mFragments.add(fragment) ;
}
// 给我们的ViewPager 设置Adapter
setAdapter(new ParallaxPagerAdapter(fm));
// 2.2.3 监听滑动改变位移
addOnPageChangeListener(new OnPageChangeListener() {
// 从第一张图 滑动到 第二张图 滑动的过程
@Override
public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
// positionOffset值是 0-1 positionOffsetPixels值是 0-屏幕的宽度px
// 获取左边出去的fragment右边进来的fragment
ParallaxFragment outFragment = mFragments.get(position) ;
List parallaxViews = outFragment.getParallaxViews();
for (View parallaxView : parallaxViews) {
ParallaxTag tag = (ParallaxTag) parallaxView.getTag(R.id.parallax_tag);
// 为什么这样写 ?
parallaxView.setTranslationX((-positionOffsetPixels)*tag.translationXOut);
parallaxView.setTranslationY((-positionOffsetPixels)*tag.translationYOut);
}
try {
ParallaxFragment inFragment = mFragments.get(position+1) ;
parallaxViews = inFragment.getParallaxViews() ;
for (View parallaxView : parallaxViews) {
ParallaxTag tag = (ParallaxTag) parallaxView.getTag(R.id.parallax_tag);
parallaxView.setTranslationX((getMeasuredWidth()-positionOffsetPixels)*tag.translationXIn);
parallaxView.setTranslationY((getMeasuredWidth()-positionOffsetPixels)*tag.translationYIn);
}
} catch (Exception e) {
e.printStackTrace();
}
}
// 已经切换完毕
// 已经滑动到具体某一页,比如滑动到第一页、滑动到第二页、滑动到第三页
@Override
public void onPageSelected(int position) {
}
@Override
public void onPageScrollStateChanged(int state) {
}
});
}
其实原则就是:
自己先把ViewPager和Fragment创建好,也就是去自定义ParallaxViewPager和ParallaxFragment,最后所需要的地方让开发者直接去给它设置一个布局数组,传递几个layout的布局文件就可以实现左右滑动的视差动画ViewPager
3. 代码如下
3.1 视差动画的ViewPager
/**
* Email: [email protected]
* Created by JackChen 2018/3/9 8:28
* Version 1.0
* Params:
* Description: 视差动画的ViewPager
*/
public class ParallaxViewPager extends ViewPager {
private List mFragments ;
public ParallaxViewPager(Context context) {
this(context , null);
}
public ParallaxViewPager(Context context, AttributeSet attrs) {
super(context, attrs);
mFragments = new ArrayList<>() ;
}
public void setLayoutId(FragmentManager fm ,int[] layoutIds){
mFragments.clear();
for (int layoutId : layoutIds) {
ParallaxFragment fragment = new ParallaxFragment() ;
Bundle bundle = new Bundle() ;
bundle.putInt(ParallaxFragment.LAYOUT_ID_KEY , layoutId);
fragment.setArguments(bundle);
mFragments.add(fragment) ;
}
// 给我们的ViewPager 设置Adapter
setAdapter(new ParallaxPagerAdapter(fm));
// 2.2.3 监听滑动改变位移
addOnPageChangeListener(new OnPageChangeListener() {
// 从第一张图 滑动到 第二张图 滑动的过程
@Override
public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
// positionOffset值是 0-1 positionOffsetPixels值是 0-屏幕的宽度px
// 获取左边出去的fragment右边进来的fragment
ParallaxFragment outFragment = mFragments.get(position) ;
List parallaxViews = outFragment.getParallaxViews();
for (View parallaxView : parallaxViews) {
ParallaxTag tag = (ParallaxTag) parallaxView.getTag(R.id.parallax_tag);
// 为什么这样写 ?
parallaxView.setTranslationX((-positionOffsetPixels)*tag.translationXOut);
parallaxView.setTranslationY((-positionOffsetPixels)*tag.translationYOut);
}
try {
ParallaxFragment inFragment = mFragments.get(position+1) ;
parallaxViews = inFragment.getParallaxViews() ;
for (View parallaxView : parallaxViews) {
ParallaxTag tag = (ParallaxTag) parallaxView.getTag(R.id.parallax_tag);
parallaxView.setTranslationX((getMeasuredWidth()-positionOffsetPixels)*tag.translationXIn);
parallaxView.setTranslationY((getMeasuredWidth()-positionOffsetPixels)*tag.translationYIn);
}
} catch (Exception e) {
e.printStackTrace();
}
}
// 已经切换完毕
// 已经滑动到具体某一页,比如滑动到第一页、滑动到第二页、滑动到第三页
@Override
public void onPageSelected(int position) {
}
@Override
public void onPageScrollStateChanged(int state) {
}
});
}
private class ParallaxPagerAdapter extends FragmentPagerAdapter {
public ParallaxPagerAdapter(FragmentManager fm) {
super(fm);
}
@Override
public Fragment getItem(int position) {
return mFragments.get(position);
}
@Override
public int getCount() {
return mFragments.size();
}
}
}
3.2 视差动画的 Fragment
/**
* Email: [email protected]
* Created by JackChen 2018/3/9 9:09
* Version 1.0
* Params:
* Description: 视差动画的 Fragment
*/
public class ParallaxFragment extends Fragment implements LayoutInflaterFactory{
public static final String LAYOUT_ID_KEY = "LAYOUT_ID_KEY" ;
private CompatViewInflater mCompatViewInflater ;
// 存放所有的需要位移的View
private List mParallaxViews = new ArrayList<>() ;
// 存放视差动画的属性
private int[] mParallaxAttrs = new int[]{R.attr.translationXIn,R.attr.translationXOut ,
R.attr.translationYIn,R.attr.translationYOut};
@Nullable
@Override
public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
// 获取布局的id
int layoutId = getArguments().getInt(LAYOUT_ID_KEY);
// 2.2.2 把所有需要移动的属性解析出来 ,内涵段子插件式换肤有
// View创建的时候我们去解析,这里传inflater是有问题的, 单例设计模式代表所有View的创建都是该 Fragment去创建的
// 克隆一个inflater出来
inflater = inflater.cloneInContext(getActivity()) ;
LayoutInflaterCompat.setFactory(inflater , this);
return inflater.inflate(layoutId , container , false);
}
/**
* 这个方法是实现LayoutInflaterFactory接口后重写的该方法
* @param parent
* @param name
* @param context
* @param attrs
* @return
*/
@Override
public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
// 所有的View都会在这里创建
// 拦截到View的创建 获取View之后要去解析
// 1.创建View
// If the Factory didn't handle it, let our createView() method try
View view = createView(parent , name ,context , attrs) ;
// 2.1 一个 activity的布局肯定对应多个这样的 SkinView
if (view != null){
// 解析所有的 我们关注的属性
analysisAttrs(view , context , attrs) ;
}
return view;
}
/**
* 解析所有的 我们关注的属性
* @param view
* @param context
* @param attrs
*/
private void analysisAttrs(View view, Context context, AttributeSet attrs) {
TypedArray array = context.obtainStyledAttributes(attrs, mParallaxAttrs);
if (array != null && array.getIndexCount() != 0){
int n = array.getIndexCount();
ParallaxTag tag = new ParallaxTag();
for (int i = 0; i < n; i++) {
int attr = array.getIndex(i);
switch (attr){
case 0:
tag.translationXIn = array.getFloat(attr , 0f) ;
break;
case 1:
tag.translationXOut = array.getFloat(attr , 0f) ;
break;
case 2:
tag.translationYIn = array.getFloat(attr , 0f) ;
break;
case 3:
tag.translationYOut = array.getFloat(attr , 0f) ;
break;
}
}
// 自定义属性怎么存? 需要一一绑定,在View上边设置tag
view.setTag(R.id.parallax_tag , tag);
mParallaxViews.add(view) ;
}
array.recycle();
}
private View createView(View parent, String name, Context context, AttributeSet attrs) {
final boolean isPre21 = Build.VERSION.SDK_INT < 21;
if (mCompatViewInflater == null){
mCompatViewInflater = new CompatViewInflater() ;
}
// We only want the View to inherit it's context if we're running pre-v21
final boolean inheritContext = isPre21 && true
&& shouldInheritContext((ViewParent) parent);
return mCompatViewInflater.createView(parent, name, context, attrs, inheritContext,
isPre21, /* Only read android:theme pre-L (L+ handles this anyway) */
true /* Read read app:theme as a fallback at all times for legacy reasons */
);
} ;
private boolean shouldInheritContext(ViewParent parent) {
if (parent == null) {
// The initial parent is null so just return false
return false;
}
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 instanceof View)
|| ViewCompat.isAttachedToWindow((View) parent)) {
// 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();
}
}
public List getParallaxViews(){
return mParallaxViews ;
}
}
3.3 CompatViewInflater代码如下
/**
* Email: [email protected]
* Created by JackChen 2018/3/9 8:30
* Version 1.0
* Params:
* Description:
*/
/**
* This class is responsible for manually inflating our tinted widgets which are used on devices
* running {@link android.os.Build.VERSION_CODES#KITKAT KITKAT} or below. As such, this class
* should only be used when running on those devices.
* This class two main responsibilities: the first is to 'inject' our tinted views in place of
* the framework versions in layout inflation; the second is backport the {@code android:theme}
* functionality for any inflated widgets. This include theme inheritance from it's parent.
*/
public class CompatViewInflater {
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 LOG_TAG = "AppCompatViewInflater";
private static final Map> sConstructorMap
= new ArrayMap<>();
private final Object[] mConstructorArgs = new Object[2];
public final View createView(View parent, final String name, @NonNull Context context,
@NonNull AttributeSet attrs, boolean inheritContext,
boolean readAndroidTheme, boolean readAppTheme) {
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 = new AppCompatTextView(context, attrs);
break;
case "ImageView":
view = new AppCompatImageView(context, attrs);
break;
case "Button":
view = new AppCompatButton(context, attrs);
break;
case "EditText":
view = new AppCompatEditText(context, attrs);
break;
case "Spinner":
view = new AppCompatSpinner(context, attrs);
break;
case "ImageButton":
view = new AppCompatImageButton(context, attrs);
break;
case "CheckBox":
view = new AppCompatCheckBox(context, attrs);
break;
case "RadioButton":
view = new AppCompatRadioButton(context, attrs);
break;
case "CheckedTextView":
view = new AppCompatCheckedTextView(context, attrs);
break;
case "AutoCompleteTextView":
view = new AppCompatAutoCompleteTextView(context, attrs);
break;
case "MultiAutoCompleteTextView":
view = new AppCompatMultiAutoCompleteTextView(context, attrs);
break;
case "RatingBar":
view = new AppCompatRatingBar(context, attrs);
break;
case "SeekBar":
view = new AppCompatSeekBar(context, attrs);
break;
}
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 it's android:onClick
checkOnClickListener(view, attrs);
}
return view;
}
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('.')) {
// try the android.widget prefix first...
return createView(context, name, "android.widget.");
} else {
return createView(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 (!ViewCompat.hasOnClickListeners(view) || !(context instanceof ContextWrapper)) {
// Skip our compat functionality if: the view doesn't have an onClickListener,
// or the Context isn't a ContextWrapper
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 createView(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 = context.getClassLoader().loadClass(
prefix != null ? (prefix + name) : name).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, android.support.v7.appcompat.R.styleable.View, 0, 0);
int themeId = 0;
if (useAndroidTheme) {
// First try reading android:theme if enabled
themeId = a.getResourceId(android.support.v7.appcompat.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(android.support.v7.appcompat.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(), mMethodName);
}
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);
}
}
@NonNull
private void resolveMethod(@Nullable Context context, @NonNull String name) {
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);
}
}
}
3.4 视差动画的 tag标记 代码如下
/**
* Email: [email protected]
* Created by JackChen 2018/3/9 9:01
* Version 1.0
* Params:
* Description: 视差动画的 tag 标记
*/
public class ParallaxTag {
public float translationXIn ;
public float translationXOut ;
public float translationYIn ;
public float translationYOut ;
@Override
public String toString() {
return "ParallaxTag{" +
"translationXIn=" + translationXIn +
", translationXOut=" + translationXOut +
", translationYIn=" + translationYIn +
", translationYOut=" + translationYOut +
'}';
}
}
3.5 activity_main的布局文件如下:
3.5 3个布局文件如下:
fragment_page_first.xml
fragment_page_second.xml
fragment_page_third.xml
具体代码已上传至github:
https://github.com/shuai999/View_day27.git