鸿蒙开源三方组件--MD风格应用引导页组件material-intro-screen 精华
1. 介绍
Material intro screen 是一款Material Design设计风格的,使用PageSlider(类似于安卓的ViewPager)来实现引导介绍页效果的的应用。该应用通过继承PageSlider并重写它的onTouchEvent,监听拦截页面滑动等方法来实现对触摸事件的监听,以及注入自定义的监听器,实现页面颜色、页面指示器颜色随着滑动渐变,页面指示器形状随着滑动动态改变,页面控件随着滑动alpha以及位移的动态变化,页面某控件的选中与否对滑动的拦截,权限的申请与否对滑动的拦截,页面惯性滑动等功能。
本应用最主要的特色为:
⦁ 轻松添加新页面
⦁ 定制页面
⦁ 视差页面
⦁ 易扩展的API
2. Material intro screen库的使用方法
本库有两种使用方法:
1.使用gradle直接添加依赖
主项目build.gradle的repositories里面添加mavenCentral()
allprojects {
repositories {
mavenCentral()
}
}
entry的build.gradle的dependencies里面添加依赖
implementation 'io.openharmony.tpc.thirdlib:material-intro-screen:1.0.1'
2.使用har
直接编译库material-intro-screen,成功之后在material-intro-screen/build/outputs/har里面找到对应的har包,复制粘贴到自己的项目的libs里面。
3. Material intro screen示例部分详解
项目结构如下图所示
SplashAbility以及SplashAbilitySlice—引导页
// 判断APP是在后台还是第一次登陆
if (MaterialIntro.isIsFirstOpen()) {
Intent secondIntent = new Intent();
// 指定待启动FA的bundleName和abilityName
Operation operation = new Intent.OperationBuilder()
.withDeviceId("")
.withBundleName("agency.tango.materialintro")
.withAbilityName("agency.tango.materialintro.MainAbility")
.build();
secondIntent.setOperation(operation);
startAbility(secondIntent); // 通过AbilitySlice的startAbility接口实现启动另一个页面
MaterialIntro.setIsFirstOpen(false);
} else {
Intent secondIntent = new Intent();
// 指定待启动FA的bundleName和abilityName
Operation operation = new Intent.OperationBuilder()
.withDeviceId("")
.withBundleName("agency.tango.materialintro")
.withAbilityName("agency.tango.materialintro.IntroAbility")
.build();
secondIntent.setOperation(operation);
startAbility(secondIntent); // 通过AbilitySlice的startAbility接口实现启动另一个页面
}
用于跳转首页或者介绍页,以及应用启动时显示背景图,由于暂时未在鸿蒙里面找到类似于安卓theme的功能,本应用启动瞬间会有一小段时间的白屏,后续待优化。使用MaterialIntro(类似安卓的application)的isFirstOpen来记录是否首次打开应用。首次进入会跳转MainAbility以及MainAbilitySlice,后续即使应用被双击退出,只要进程还在,再次点击应用图标进入APP也是直接进入介绍页IntroAbility以及IntroSlice,不会进入首页。
IntroAbility以及IntroSlice—介绍页
继承自MaterialIntroAbility以及MaterialIntroSlice,主要用于扩展pageSlider,添加pageSlider子条目,设置不同的位移包装器,以此灵活的实现不同的界面不同的显示,不同的控件不同的行为。需要注意的是,在申请权限界面添加测试权限的时候,受限权限(eg:SystemPermission.READ_MESSAGES和SystemPermission.PLACE_CALL)必须在获取签名的网站https://developer.huawei.com/consumer/cn/service/josp/agc/index.html#/创建应用的时候申明,否则项目里申请了该权限却没有在网站获取签名信息的时候申请会导致APP无法安装,报错Error while Deploying HAP。
private void addPermissionPage() {
//原库的SystemPermission.READ_MESSAGES SystemPermission.PLACE_CALL权限必须在申请签名的时候在网页授予,否则应用无法安装
// 所以这里替换掉这两个测试的权限
String[] possiblePermissions = {SystemPermission.INTERNET,
SystemPermission.GET_WIFI_INFO};
String[] neededPermissions = {SystemPermission.CAMERA,
SystemPermission.LOCATION,
SystemPermission.LOCATION_IN_BACKGROUND};
SlideView permissionView = new SlideViewBuilder()
.backgroundColor(ResourceTable.Color_third_slide_background) //设置页面背景色
.buttonsColor(ResourceTable.Color_third_slide_buttons) // 设置按钮颜色
.possiblePermissions(possiblePermissions) // 设置非必须权限
.neededPermissions(neededPermissions) // 设置必要权限
.image(ResourceTable.Media_img_equipment) // 设置页面显示的图片
.title(getContext().getString(ResourceTable.String_permission_title)) // 设置页面显示的文本的标题
.description(getContext().getString(ResourceTable.String_permission_description)) // 设置页面显示的文本的介绍
.build(getContext());
MessageButtonBehaviour permissionButton =
new MessageButtonBehaviour(view ->
showMessage(getContext().getString(ResourceTable.String_permission_button_message)),
getContext().getString(ResourceTable.String_permission_button_text));
addSlide(permissionView, permissionButton);
}
CustomSlide—自定义的页面
以该页面的选择控件的选中与否来决定是否拦截页面向右滑动。canMoveFurther为是否拦截滑动,cantMoveFurtherErrorMessage()是滑动被拦截时显示的文字信息。
4. Material intro screen核心库详解
核心库主要分为以下几个模块:
- 适配器adapter(条目加载、删除刷新,状态判断等);
- animations(alpha动画,translation动画);
- interceptor(加速、减速、循环等类型的差值器);
- listeners(点击监听器,页面滑动监听器,触摸监听器等);
- parallax(自定义视差布局),slice(控件初始化,监听器注册等);
- utils(工具类,用于计算渐变颜色,rgb和argb颜色转变,吐司和log等);
- widgets(自定义控件);
- item基类,自定义属性等。
核心库提供自定义的OverScrollViewPager供用户使用,注意不要使用SwipeableViewPager,否则无法做惯性滑动以及滑动回弹。在基类MaterialIntroSlice中默认提供了界面颜色随滑动渐变,返回、跳过、下一页、消息页、标题、内容文本随界面滑动的位移动画,滑动拦截检测,权限检测等功能。如果用户对此效果不满意,可以继承MaterialIntroSlice,重写onStart方法,在里面添加自己的视图控件和监听器等;如果用户仅仅是需要替换pageSlider某个条目,则只需要继承SlideView来实现相关方法,然后在IntroSlice里面的相应位置addSlide即可。
⦁ 核心类讲解
4.1 自定义的pageSlider—SwipeableViewPager。
SwipeableViewPager继承自pageSlider,重写了onTouchEvent,以此实现触摸事件的监听与拦截。由于鸿蒙当前暂未提供onterceptTouchEvent对应的接口,无法做事件分发,这里的pageSlider为了可以滑动 TouchEvent.PRIMARY_POINT_DOWN事件必须返回true,这导致想把触摸事件分发给别的控件他们都无法调用PRIMARY_POINT_DOWN获取滑动起始位置,无法计算出滑动位置。这里采取的解决方法是自定义一个接口GiveTouchToParentListener,分别在合适的时机调用down、move、up方法,将触摸事件传递分发给实现了接口的控件,从而变相的实现事件分发。核心代码如下:
@Override
public boolean onTouchEvent(Component component, TouchEvent event) {
boolean avaliable = doTouchEvent(component, event);
if (touchEventListener != null) {
return touchEventListener.onTouchEvent(component, event);
}
return avaliable;
}
private boolean doTouchEvent(Component component, TouchEvent event) {
int action = event.getAction();
switch (action) {
case (TouchEvent.PRIMARY_POINT_DOWN):
doTouchDown(component, event);
return true;
case (TouchEvent.POINT_MOVE):
float moveX = startPos - event.getPointerScreenPosition(0).getX();
float moveY = startPosY - event.getPointerScreenPosition(0).getY();
//如果不能向右滑动 且滑动方向是向右滑动 禁止掉pageSlider的滑动功能
if (!swipingAllowed && moveX > 0) {
setSlidingPossible(false);
return true;
}
setSlidingPossible(true);
// 屏蔽竖直滚动
if (Math.abs(moveY) > Math.abs(moveX)) {
return false;
}
//如果是最后一页
if (getAdapter() != null && listener != null && getAdapter().isLastSlide(currentIt)) {
// 且是向右滑动 则将滑动分发给父类处理
if (moveX > 0) {
listener.onTouchMove(component, event);
return false;
} else {
// 如果是向左滑动 则自己消费,且需要将父类的translateX置为0
listener.onTransToOrigin(component, event);
return true;
}
} else {
return true;
}
case (TouchEvent.PRIMARY_POINT_UP):
float moveUpX = startPos - event.getPointerScreenPosition(0).getX();
float moveUpY = startPosY - event.getPointerScreenPosition(0).getY();
//如果是最后一页 且向右滑动 触摸事件交给父类处理
if (getAdapter() != null && listener != null && moveUpX > 0 && getAdapter().isLastSlide(currentIt)) {
listener.onTouchUp(component, event);
return false;
}
//如果无法向右滑动 且有滑动距离 需要将控件回弹原位
if (!swipingAllowed && moveUpX > 0) {
scrollTo(getWidth() * currentIt, 0);
return true;
}
// 屏蔽竖直滚动
if (Math.abs(moveUpY) > Math.abs(moveUpX)) {
return false;
}
startPos = 0;
break;
default:
break;
}
return false;
}
public void doTouchDown(Component component, TouchEvent event) {
// 记录滑动起始位置
startPos = event.getPointerScreenPosition(0).getX();
startPosY = event.getPointerScreenPosition(0).getY();
// 记录当前页
currentIt = getCurrentPage();
//设置是否可以向右滑动到下一页
resolveSwipingRightAllowed();
if (listener != null) {
listener.onTouchDown(component, event);
}
}
⦁ TouchEvent.PRIMARY_POINT_DOWN在这里获取触摸事件的起始位置坐标点,切记使用getPointerScreenPosition方法而不是getPointerPosition,因为后者获取的相对位置,在move的时候获取的第一个值与down的时候的起始值有很大的差值,不连续,会造成计算位移距离的偏差。
⦁ TouchEvent.PRIMARY_POINT_DOWN一直返回true来首先拦截触摸事件,如果返回false则页面不会滑动,拦截页面滑动的时候不能直接返回false,否则左滑事件也将屏蔽, 所以这里通过接口GiveTouchToParentListener的onTouchDown将起始值传递出去。同时需要在这里调用resolveSwipingRightAllowed()设置是否可以右滑。
⦁ TouchEvent.POINT_MOVE滑动事件,通过setSlidingPossible(false)禁止pageSlider的滑动功能来实现阻止pageSlider的右滑。
⦁ TouchEvent.POINT_MOVE滑动事件,事件是否分发给父类则是通过return true/false来决定是否消费掉滑动事件,是否返回给需要处理的控件。同时利用GiveTouchToParentListener的onTouchMove传递滑动事件。
⦁ PRIMARY_POINT_UP手指抬起事件,事件的分发和处理逻辑类似 TouchEvent.POINT_MOVE,主要是处理最后一页的惯性滑动,以及手指抬起控件惯性滑动之后的回弹或者页面关闭。
⦁ MOVE和UP事件都要过滤掉竖直方向的滑动以优化。
⦁ SwipeableViewPager 重写了addPageChangedListener,将页面变化监听器存入集合mOnPageChangeListeners,在本控件监听到页面变化之后再分发给注册监听器的子控件。核心代码在onPageSliding方法中,代码如下:
@Override
public void onPageSliding(int position, float positionOffset,
int positionOffsetPixels) {
int tempPosition = position;
float tempPositionOffset = positionOffset;
if (getAdapter() == null) {
return;
}
float width = (float) getWidth(); //获取pageslider页面宽度
int currentPage = getCurrentPage();
float scrollX = 0f;
if (currentPage >= 0 && currentPage < getAdapter().getCount()
&& getAdapter().getItem(currentPage) != null
&& getAdapter().getItem(currentPage).getRootView() != null) {
float parentLeft = (float) getLocationOnScreen()[0]; //获取pageslider页面在屏幕最左边的X坐标
float itemX = (float) getAdapter().getItem(currentPage).getRootView().getLocationOnScreen()[0]; //获取当前条目在屏幕最左边的X坐标
scrollX = itemX - parentLeft; // 计算滑动距离
}
if (width > 0) {
if (scrollX <= 0f) { // 向右滑动
tempPosition = currentPage;
tempPositionOffset = Math.abs(scrollX) / Math.abs(width);
} else { // 向左滑动
tempPosition = currentPage - 1;
tempPositionOffset = 1 - (Math.abs(scrollX) / Math.abs(width));
}
}
if (tempPosition >= getAdapter().getCount()) {
tempPosition = getAdapter().getCount() - 1;
}
if (tempPosition < 0) {
tempPosition = 0;
}
if (tempPositionOffset < 0) {
tempPositionOffset = 0;
}
if (tempPositionOffset > 1) {
tempPositionOffset = 1;
}
if (mOnPageChangeListeners == null) {
return;
}
for (int i = 0; i < mOnPageChangeListeners.size(); i++) {
if (mOnPageChangeListeners.get(i) != null) {
mOnPageChangeListeners.get(i).onPageSliding(tempPosition,
tempPositionOffset, positionOffsetPixels);
}
}
}
此方法对应安卓的onPageScrolled方法,但是他们传递的参数的值不一样,这会导致页面滑动的时候颜色渐变和位移动画错乱,需要进行对应的适配。这里采取的做法是获取SwipeableViewPager在屏幕左边的绝对坐标,以及当前页面的item对应的左边绝对坐标,通过他们的差值以及SwipeableViewPager的宽度来重新计算偏移量,偏移距离,position的值则可以使用getCurrentPage来修正,本文这里只修正了位置和偏移量这两个值。
4.2 惯性滑动控件OverScrollViewPager
在SwipeableViewPager滑动到最后一页的时候需要做惯性滑动多滑出一段距离(页面偏移量大于0.5)并退出。需要给父容器OverScrollViewPager设置setTouchToParentListener(this),同时自身实现接口GiveTouchToParentListener用于处理传递给它的触摸事件,注意:onTouchEvent 返回true,关键代码如下:
@Override
public void onTouchDown(Component component, TouchEvent event) {
mMotionBeginX = event.getPointerScreenPosition(0).getX();
}
@Override
public void onTouchMove(Component component, TouchEvent event) {
moveOffset = event.getPointerScreenPosition(0).getX() - mMotionBeginX;
positionOffset = calculateOffset(moveOffset);
if (moveOffset < 0) {
//由于控件的scrollTo无效 这里采用动态改变TranslationX来实现过度滑动的效果
setTranslationX(moveOffset);
setAlpha(1 - positionOffset);
//为了让界面的控件有跟随的退场动画 这里调用一下子控件的onPageSliding
requestChildSliding();
}
//如果页面偏移量超过一半 则关闭介绍页
if (positionOffset > 0.5f) {
finishListener.doOnFinish();
}
}
@Override
public void onTouchUp(Component component, TouchEvent event) {
moveOffset = event.getPointerScreenPosition(0).getX() - mMotionBeginX;
//如果页面偏移量超过一半 则关闭介绍页
if (positionOffset > 0.5f) {
finishOverScrollViewWithAnimation(moveOffset);
} else {
//否则页面多余的水平位移置为0 alpha置为1 然后再将页面回弹
setTranslationX(0);
setAlpha(1);
resetOverScrollViewWithAnimation(moveOffset);
}
}
@Override
public void onTransToOrigin(Component component, TouchEvent event) {
setTranslationX(0);
setAlpha(1);
}
private void requestChildSliding() {
if (swipeableViewPager != null && swipeableViewPager.getOnPageChangeListeners() != null) {
for (int i = 0; i < swipeableViewPager.getOnPageChangeListeners().size(); i++) {
PageSlider.PageChangedListener pageChangedListener =
swipeableViewPager.getOnPageChangeListeners().get(i);
if (pageChangedListener != null) {
pageChangedListener.onPageSliding(swipeableViewPager.getAdapter().getLastItemPosition(), positionOffset, 0);
}
}
}
}
/**
* 随着动画重置本容器状态
*
* @param currentX 本容器偏移量
*/
private void resetOverScrollViewWithAnimation(final float currentX) {
EventHandler current =
new EventHandler(EventRunner.getMainEventRunner());
current.postTask(new SmoothScrollRunnable((int) currentX, 0, 300,
new AccelerateInterpolator()));
}
/**
* 随着动画关闭本容器页面
*
* @param currentX 本容器偏移量
*/
private void finishOverScrollViewWithAnimation(float currentX) {
EventHandler current =
new EventHandler(EventRunner.getMainEventRunner());
current.postTask(new SmoothScrollRunnable((int) currentX, -getWidth(), 300,
new AccelerateInterpolator()));
}
⦁ OverScrollViewPager的惯性滑动是SwipeableViewPager通过GiveTouchToParentListener分发,相关逻辑判断由SwipeableViewPager完成。
⦁ 滑动过程中如果滑动偏移量超过页面的一半,则直接调用finishListener.doOnFinish()关闭介绍页回到主页。
⦁ 手指抬起之后触摸事件结束,判断滑动的偏移量,偏移量超过界面的一半则会调用finishOverScrollViewWithAnimation关闭页面,否则调用resetOverScrollViewWithAnimation将页面置为起点位置或者滚动到下一页。
⦁ 利用插值器来控制滑动回弹的动画效果:finishOverScrollViewWithAnimation和resetOverScrollViewWithAnimation方法的核心是创建了一个线程SmoothScrollRunnable,并在线程执行完毕的时候递归调用主线程的handler发送自身继续执行,并利用差值器和duration计算移动距离,直到将OverScrollViewPager和SwipeableViewPager都按照动画效果移动到需要移动的位置,调用结束,触摸事件结束。相关实现如下:
@Override
public void run() {
if (startTime == -1) {
startTime = System.currentTimeMillis();
} else {
long normalizedTime =
(1000 * (System.currentTimeMillis() - startTime)) / duration;
normalizedTime = Math.max(Math.min(normalizedTime, 1000), 0);
final int deltaY =
Math.round((scrollFromPosition - scrollToPosition)
* interpolator.getInterpolation(normalizedTime / 1000f));
currentPosition = scrollFromPosition - deltaY;
moveOverScrollView(currentPosition);
}
if (scrollToPosition != currentPosition) {
new EventHandler(EventRunner.getMainEventRunner()).postTask(this);
}
}
⦁ OverScrollViewPager在最后一页向右惯性滑动的同时会伴随着当前页面alpha透明度的渐变,页面渐渐透明,但是由于暂时未在鸿蒙找到页面透明的方法以及API差异,所以和安卓比起来,滑动之后界面下面会出现白色底色而非透明,这里等后续API完善之后需要继续优化。
4.3 自定义的页面滑动指示器InkPageIndicator。
InkPageIndicator继承自Component,主要实现onDraw(类似安卓的onDraw)和onEstimateSize(类似安卓的onMesuare)方法。
onEstimateSize方法里面先确定控件的高度:
@Override public boolean onEstimateSize(int widthMeasureSpec, int heightMeasureSpec) { int desiredHeight = getDesiredHeight(); int height; switch (EstimateSpec.getMode(heightMeasureSpec)) { case EstimateSpec.PRECISE: height = EstimateSpec.getSize(heightMeasureSpec); break; case EstimateSpec.NOT_EXCEED: height = Math.min(desiredHeight, EstimateSpec.getSize(heightMeasureSpec)); break; default: height = desiredHeight; break; } int desiredWidth = getDesiredWidth(); int width; switch (EstimateSpec.getMode(widthMeasureSpec)) { case EstimateSpec.PRECISE: width = EstimateSpec.getSize(widthMeasureSpec); break; case EstimateSpec.NOT_EXCEED: width = Math.min(desiredWidth, EstimateSpec.getSize(widthMeasureSpec)); break; default: width = desiredWidth; break; } setEstimatedSize(EstimateSpec.getSizeWithMode(width, EstimateSpec.PRECISE), EstimateSpec.getSizeWithMode(height, EstimateSpec.PRECISE)); calculateDotPositions(width); return true; }
⦁ onEstimateSize返回值需要为true,否则前面设置的宽高不起效果。
⦁ setEstimatedSize的时候注意要将宽高转换 EstimateSpec.getSizeWithMode(width, EstimateSpec.PRECISE),否则设置的数值不对,导致界面显示异常。
⦁ 需要进行测量模式的判断:EstimateSpec.PRECISE模式(已经确定了确切尺寸),宽高直接使用测量出来的;EstimateSpec.NOT_EXCEED模式(指定了最大尺寸),宽高使用计算出的宽高和测量出的两个里面的最小值;其他模式则是直接使用计算出来的值。
⦁ 小圆点的直径和圆点间距需要使用到自定义属性值,方法是定义一个常量类AttrString,定义属性名
/** * pageslider指示器小圆点直径 */ public static String INKPAGEINDICATOR_DOTDIAMETER = "dotDiameter";
然后在XML里面引用,
<agency.tango.materialintroscreen.widgets.InkPageIndicator ohos:id="$+id:indicator" ohos:height="match_content" ohos:width="0vp" ohos:layout_alignment="bottom" ohos:weight="1" zdy:animationDuration="320" ohos:bottom_margin="16vp" zdy:currentPageIndicatorColor="#FFFFFF" zdy:dotDiameter="8" //不要带单位 默认单位vp zdy:dotGap="8" zdy:pageIndicatorColor="#ff4444"/>
最后在自定义控件里面通过AttrSet来解析自定义属性。
final int density =context.getResourceManager().getDeviceCapability().screenDensity / 160; dotDiameter =attrs.getAttr(AttrString.INKPAGEINDICATOR_DOTDIAMETER).isPresent() ? attrs.getAttr(AttrString.INKPAGEINDICATOR_DOTDIAMETER).get().getIntegerValue()* density : DEFAULT_DOT_SIZE * density;
在onDraw里面绘制指示器:
⦁ 分别为绘制未选中小圆点drawUnselected()和选中小圆点drawSelected()。drawSelected()是通过Path的addCircle方法直接绘制选中的小圆点路径。
⦁ drawUnselected()方法里面通过Path的addCircle方法添加未选中小圆点的路径,通过Path的arcTo和cubicTo方法来绘制滑动时相关小圆点的变形圆弧曲线,贝塞尔曲线。
⦁ drawUnselected()方法里面小圆点的路径的计算是通过pageSlider的onPageSliding方法传递的页面偏移量来计算,相关代码如下:
页面偏移量为0
不需要偏移,直接画圆
if (isDotNotJoining(page, joiningFraction, dotRevealFraction)) { unselectedDotPath.addCircle(dotCenterX[page], dotCenterY, dotRadius, Path.Direction.CLOCK_WISE);}
偏移量小于0.5
页面切换时,指示器有一个拉伸变形的动画效果,如截图红框标注所示, 它是通过在当前页面的圆点指示器中心点和下一个页面的圆点指示器中心点往左和右分别画贝塞尔曲线实现圆弧运动效果,水平方向的控制点(曲线最远点)是根据偏移量和小圆点之间的间距来计算
private void addLeftPath(float centerX, float joiningFraction) { unselectedDotLeftPath.rewind(); unselectedDotLeftPath.moveTo(centerX, dotBottomY); rectF.modify(centerX - dotRadius, dotTopY, centerX + dotRadius, dotBottomY); unselectedDotLeftPath.arcTo(rectF, 90, 180, true); endX1 = centerX + dotRadius + (joiningFraction * gap); endY1 = dotCenterY; controlX1 = centerX + halfDotRadius; controlY1 = dotTopY; controlX2 = endX1; controlY2 = endY1 - halfDotRadius; unselectedDotLeftPath.cubicTo(new Point(controlX1, controlY1), new Point(controlX2, controlY2), new Point(endX1, endY1)); endX2 = centerX; endY2 = dotBottomY; controlX1 = endX1; controlY1 = endY1 + halfDotRadius; controlX2 = centerX + halfDotRadius; controlY2 = dotBottomY; unselectedDotLeftPath.cubicTo(new Point(controlX1, controlY1), new Point(controlX2, controlY2), new Point(endX2, endY2)); unselectedDotPath.addPath(unselectedDotLeftPath); } private void addRightPath(float nextCenterX, float joiningFraction) { unselectedDotRightPath.rewind(); unselectedDotRightPath.moveTo(nextCenterX, dotBottomY); rectF.modify(nextCenterX - dotRadius, dotTopY, nextCenterX + dotRadius, dotBottomY); unselectedDotRightPath.arcTo(rectF, 90, -180, true); endX1 = nextCenterX - dotRadius - (joiningFraction * gap); endY1 = dotCenterY; controlX1 = nextCenterX - halfDotRadius; controlY1 = dotTopY; controlX2 = endX1; controlY2 = endY1 - halfDotRadius; unselectedDotRightPath.cubicTo(new Point(controlX1, controlY1), new Point(controlX2, controlY2), new Point(endX1, endY1)); endX2 = nextCenterX; endY2 = dotBottomY; controlX1 = endX1; controlY1 = endY1 + halfDotRadius; controlX2 = endX2 - halfDotRadius; controlY2 = dotBottomY; unselectedDotRightPath.cubicTo(new Point(controlX1, controlY1), new Point(controlX2, controlY2), new Point(endX2, endY2)); unselectedDotPath.addPath(unselectedDotRightPath); }
偏移量在0.5到1
同样是在当前页面的圆点指示器的中心点和下一个页面的圆点指示器的中心点往左和右画圆弧曲线,水平方向控制点处于两个小圆点之间的间距的中心位置
private void drawPathMoreThanHalf(float centerX, float nextCenterX, float joiningFraction) { float adjustedFraction = (joiningFraction - 0.2f) * 1.25f; unselectedDotPath.moveTo(centerX, dotBottomY); rectF.modify(centerX - dotRadius, dotTopY, centerX + dotRadius, dotBottomY); unselectedDotPath.arcTo(rectF, 90, 180, true); endX1 = centerX + dotRadius + (gap / 2); endY1 = dotCenterY - (adjustedFraction * dotRadius); controlX1 = endX1 - (adjustedFraction * dotRadius); controlY1 = dotTopY; controlX2 = endX1 - ((1 - adjustedFraction) * dotRadius); controlY2 = endY1; unselectedDotPath.cubicTo(new Point(controlX1, controlY1), new Point(controlX2, controlY2), new Point(endX1, endY1)); endX2 = nextCenterX; endY2 = dotTopY; controlX1 = endX1 + ((1 - adjustedFraction) * dotRadius); controlY1 = endY1; controlX2 = endX1 + (adjustedFraction * dotRadius); controlY2 = dotTopY; unselectedDotPath.cubicTo(new Point(controlX1, controlY1), new Point(controlX2, controlY2), new Point(endX2, endY2)); rectF.modify(nextCenterX - dotRadius, dotTopY, nextCenterX + dotRadius, dotBottomY); unselectedDotPath.arcTo(rectF, 270, 180, true); endY1 = dotCenterY + (adjustedFraction * dotRadius); controlX1 = endX1 + (adjustedFraction * dotRadius); controlY1 = dotBottomY; controlX2 = endX1 + ((1 - adjustedFraction) * dotRadius); controlY2 = endY1; unselectedDotPath.cubicTo(new Point(controlX1, controlY1), new Point(controlX2, controlY2), new Point(endX1, endY1)); endX2 = centerX; endY2 = dotBottomY; controlX1 = endX1 - ((1 - adjustedFraction) * dotRadius); controlY1 = endY1; controlX2 = endX1 - (adjustedFraction * dotRadius); controlY2 = endY2; unselectedDotPath.cubicTo(new Point(controlX1, controlY1), new Point(controlX2, controlY2), new Point(endX2, endY2)); }
偏移量等于1
以当前页面的圆点指示器的中心点和下一个页面的圆点指示器的中心点作为起始位置和结束位置,以addRoundRect方法画圆角矩形。
⦁ 页面选择之后,可能页面不会处于正中间位置,pageSlider会将页面回弹,此时指示器会配合动画PendingRetreatAnimator,PendingRevealAnimator,PendingStartAnimator来将圆点指示器重置为默认圆形状态,路径偏移值是通过动画的的差值器和属性值监听器来计算,计算完毕之后通过Path的addPath方法将所有路径串连起来。相关代码如下:
public class PendingRetreatAnimator extends PendingStartAnimator { PendingRetreatAnimator(int was, int now, int steps, StartPredicate predicate) { super(predicate); setDuration(animHalfDuration); setCurve(interpolator); // work out the start/end values of the retreating join from the // direction we're // travelling in. Also look at the current selected dot // position, i.e. we're moving on // before a prior anim has finished. final float initialX1 = now > was ? Math.min(dotCenterX[was], selectedDotX) - dotRadius : dotCenterX[now] - dotRadius; final float finalX1 = now > was ? dotCenterX[now] - dotRadius : dotCenterX[now] - dotRadius; final float initialX2 = now > was ? dotCenterX[now] + dotRadius : Math.max(dotCenterX[was], selectedDotX) + dotRadius; final float finalX2 = now > was ? dotCenterX[now] + dotRadius : dotCenterX[now] + dotRadius; revealAnimations = new PendingRevealAnimator[steps]; // hold on to the indexes of the dots that will be hidden by the // retreat so that // we can initialize their revealFraction's i.e. make sure // they're hidden while the // reveal animation runs final int[] dotsToHide = new int[steps]; if (initialX1 != finalX1) { setFloatValues(initialX1, finalX1); for (int i = 0; i < steps; i++) { revealAnimations[i] = new PendingRevealAnimator(was + i, new RightwardStartPredicate(dotCenterX[was + i])); dotsToHide[i] = was + i; } setValueUpdateListener((animatorValue, value) -> { retreatingJoinX1 = value; getContext().getUITaskDispatcher().asyncDispatch(InkPageIndicator.this::invalidate); for (PendingRevealAnimator pendingReveal : revealAnimations) { pendingReveal.startIfNecessary(retreatingJoinX1); } }); } else { setFloatValues(initialX2, finalX2); for (int i = 0; i < steps; i++) { revealAnimations[i] = new PendingRevealAnimator(was - i, new LeftwardStartPredicate(dotCenterX[was - i])); dotsToHide[i] = was - i; } setValueUpdateListener((animatorValue, value) -> { retreatingJoinX2 = value; getContext().getUITaskDispatcher().asyncDispatch(InkPageIndicator.this::invalidate); for (PendingRevealAnimator pendingReveal : revealAnimations) { pendingReveal.startIfNecessary(retreatingJoinX2); } }); } initListener(dotsToHide, initialX1, initialX2); } private void initListener(int[] dotsToHide, final float initialX1, final float initialX2) { setStateChangedListener(new MyAnimatorStateListener() { @Override public void onStart(Animator animator) { clearJoiningFractions(); for (int dot : dotsToHide) { setDotRevealFraction(dot, MINIMAL_REVEAL); } retreatingJoinX1 = initialX1; retreatingJoinX2 = initialX2; getContext().getMainTaskDispatcher().asyncDispatch(InkPageIndicator.this::invalidate); } @Override public void onEnd(Animator animator) { retreatingJoinX1 = INVALID_FRACTION; retreatingJoinX2 = INVALID_FRACTION; getContext().getMainTaskDispatcher().asyncDispatch(InkPageIndicator.this::invalidate); } }); } }
⦁ 路径计算完毕之后使用canvas.drawPath绘制出整个控件,注意在ondraw里面不要直接调用刷新方法,正确的使用方法是:getContext().getUITaskDispatcher().asyncDispatch(this::invalidate);
指示器的使用方法为:
pageIndicator = (InkPageIndicator) findComponentById(ResourceTable.Id_indicator); //初始化 pageIndicator.setViewPager(viewPager);
pageIndicatorTranslationWrapper =new PageIndicatorTranslationWrapper(pageIndicator); //设置动画包装器
pageIndicator.setPageIndicatorColor(buttonsColor); //设置指示器颜色
4.4 自定义仿安卓的底部弹出snackBar— BottomSnackBar
实现原理是自定义BottomSnackBar布局并加载进来,然后在页面最顶层的容器addComponent将布局component加入进去,并且设置translateY为页面的高度,让布局显示在界面下方,点击控件之后,开启动画动态改变translateY让布局从底部弹出,隐藏的时候则是反向动画让布局位移到界面下方。
defaultSnackBar = LayoutScatter.getInstance(getContext()).parse(ResourceTable.Layout_bottom_snackbar, this, false); LayoutConfig config = new LayoutConfig(LayoutConfig.MATCH_PARENT, LayoutConfig.MATCH_CONTENT); removeAllComponents(); addComponent(defaultSnackBar, config);
显示的关键代码如下:
private void beginAnimation(Component clickView) { MyValueAnimator property = MyValueAnimator.ofFloat(0, -BOTTOM_SNACKBAR_HEIGHT); property.setCurveType(Animator.CurveType.ACCELERATE); property.setDuration(builder.animationDuration); property.setValueUpdateListener((animatorValue, value) -> { float v1 = Math.abs(value / BOTTOM_SNACKBAR_HEIGHT); BottomSnackBar.this.setAlpha(v1); float v2 = DEFAULT_TRANS_Y + value; BottomSnackBar.this.setTranslationY((int) v2); clickView.setTranslationY((int) value); }); addAnimaListener(property, false, clickView); property.start(); }
4.5 视差视图ParallaxView
视差视图就是让界面控件随着界面滑动一起联动,从而打造出绚丽的动画效果。这里的实现效果是随着pageSlider的滑动,界面某些控件也随之做不同程度的水平滑动。
使用用法是使用自定义的ParallaxFrameLayout,ParallaxLinearLayout,ParallaxRelativeLayout作为XML根布局,然后给需要联动的子控件设置自定义属性偏移比例layout_parallaxFactor。
视差控件的使用方法为:
<?xml version="1.0" encoding="utf-8"?><agency.tango.materialintroscreen.parallax.ParallaxLinearLayout xmlns:ohos="http://schemas.huawei.com/res/ohos" xmlns:zdy="http://schemas.huawei.com/res/ohos/zdy" ohos:id="$+id:slide_background" ohos:height="match_parent" ohos:width="match_parent" ohos:alignment="center" ohos:background_element="#00ffffff" ohos:left_margin="16vp" ohos:orientation="vertical" ohos:right_margin="16vp" ohos:top_margin="16vp"> <Image ohos:id="$+id:image_slide" ohos:height="0vp" ohos:width="match_parent" ohos:bottom_margin="8vp" ohos:layout_alignment="center" ohos:left_margin="48vp" ohos:right_margin="48vp" ohos:top_margin="48vp" ohos:weight="1" zdy:layout_parallaxFactor="0.6" /> <Text ohos:id="$+id:txt_title_slide" ohos:height="match_content" ohos:width="match_parent" ohos:text="Lorem ipsum" ohos:text_alignment="center" ohos:text_color="#FFFFFF" ohos:text_size="24fp" zdy:layout_parallaxFactor="0.5"/> <Text ohos:id="$+id:txt_description_slide" ohos:height="match_content" ohos:width="match_parent" ohos:bottom_margin="164vp" ohos:text="Lorem ipsum dolor sit amet, consectetur, adipisci velit, …" ohos:text_alignment="center" ohos:text_color="#FFFFFF" ohos:text_size="16fp" zdy:layout_parallaxFactor="0.2" /></agency.tango.materialintroscreen.parallax.ParallaxLinearLayout>
视差视图的原理是使用视差视图作为根布局,然后找到所有的子控件,获取子控件的自定义属性layout_parallaxFactor,如果存在则该子控件需要随着页面滑动联动,子控件滑动偏移量为容器宽度减去页面的偏移量与自定义属性(偏移比例)的乘积,主要方法为setOffset和setTranslationX。
核心代码如下:
@Override public void setOffset(float offset) { for (int i = getChildCount() - 1; i >= 0; i--) { Component child = getComponentAt(i); ParallaxLinearLayout.LayoutConfig param = (LayoutConfig) child.getLayoutConfig(); if (param.parallaxFactor == 0) { continue; } float translationX = getWidth() * -offset * param.parallaxFactor; child.setTranslationX(translationX); } } /** * 自定义控件参数 */ public static class LayoutConfig extends DirectionalLayout.LayoutConfig { float parallaxFactor = 0f; LayoutConfig(Context context, AttrSet attributeSet) { super(context, attributeSet); parallaxFactor = attributeSet.getAttr(AttrString.PARALlAX_LAYOUT_FACTOR).isPresent() ? attributeSet.getAttr(AttrString.PARALlAX_LAYOUT_FACTOR).get().getFloatValue() : parallaxFactor; } LayoutConfig(int width, int height) { super(width, height); } LayoutConfig(int width, int height, int gravity, float weight) { super(width, height, gravity, weight); } }
4.6 位移动画包装器和页面滑动监听器ViewBehavioursOnPageChangeListener和ViewTranslationWrappe
ViewBehavioursOnPageChangeListener继承自PageSlider.PageChangedListener,同时扩展了该类,可以灵活地添加ViewTranslationWrapper,PageSelectedListener,PageScrolledListener。这样可以根据不同的判断条件很方便的为页面,控件添加组合各种各样的滑动动画,进场动画,退场动画,位移动画。如果需要该类还可以扩展,在pageSlider添加子条目的时候动态替换MaterialIntroSlice默认设置的各种监听器动画效果,替换为自己实现的。使用方法如下:
private void initOnPageChangeListeners() { messageButtonBehaviourOnPageSelected = new MessageButtonBehaviourOnPageSelected(messageButton, adapter, messageButtonBehaviours); backButtonTranslationWrapper = new BackButtonTranslationWrapper(backButton); pageIndicatorTranslationWrapper = new PageIndicatorTranslationWrapper(pageIndicator); viewPagerTranslationWrapper = new ViewPagerTranslationWrapper(viewPager); skipButtonTranslationWrapper = new SkipButtonTranslationWrapper(skipButton); overScrollLayout.registerFinishListener(this::performFinish); viewPager.addOnPageChangeListeners(new ViewBehavioursOnPageChangeListener(adapter) .registerViewTranslationWrapper(nextButtonTranslationWrapper) .registerViewTranslationWrapper(backButtonTranslationWrapper) .registerViewTranslationWrapper(pageIndicatorTranslationWrapper) .registerViewTranslationWrapper(viewPagerTranslationWrapper) .registerViewTranslationWrapper(skipButtonTranslationWrapper) .registerOnPageScrolled((position, offset) -> { Runnable runnable = () -> { if (adapter.getItem(position).hasNeededPermissionsToGrant() || !adapter.getItem(position).canMoveFurther()) { viewPager.setCurrentPage(position, true); pageIndicator.clearJoiningFractions(); } }; dealMainTak(runnable); }) .registerOnPageScrolled(new ColorTransitionScrollListener()) .registerOnPageScrolled(new ParallaxScrollListener(adapter)) .registerPageSelectedListener(messageButtonBehaviourOnPageSelected) .registerPageSelectedListener(position -> { nextButtonBehaviour(position, adapter.getItem(position)); if (adapter.shouldFinish(position)) { performFinish(); } })); }
ViewTranslationWrapper则是控件位移动画包装器,里面主要包含进场动画,退场动画,位移动画,alpha动画,错误动画接口,任一实现了这些接口都可以自定义想要的动画效果,通过setEnterTranslation,setExitTranslation,setErrorAnimation等方法很容易添加、替换自定义的动画效果而不需要改动原代码。使用方法如下:
public class NextButtonTranslationWrapper extends ViewTranslationWrapper { public NextButtonTranslationWrapper(Component view) { super(view); AnimatorProperty property = new AnimatorProperty(view); property.moveByX(10f).setDuration(1200) .setCurve(new CycleInterpolator(3)); setExitTranslation(new ExitDefaultTranslation()) .setDefaultTranslation(new DefaultPositionTranslation()) .setErrorAnimation(property); }}
5. 下载链接
5.1 IDE下载链接
https://developer.harmonyos.com/cn/develop/deveco-studio#download
👍👍👍
非常详细的实现过程和经验总结。