Android窗口动画

Ardroid系统动画(一)窗口动画

什么是窗口动画?

Android窗口动画是Android系统动画的一种,当一个非Activity主窗口,非壁纸窗口添加或者移除的时候会触 发Android窗口动画的流程,举个例子 Toast的弹出或者移除的过程中就会触发窗口动画。

窗口动画流程

我把窗口动画的执行流程大概分为以下几步

  1. 触发添加窗口动画
  2. 加载对应的动画资源
  3. 创建leash,同时把执行动画的窗口容器reparent到 leash上
  4. 开始循环执行窗口动画
  5. 动画结束,把执行动画的窗口重新reparent到该容器原来的父节点上。

一. 触发添加窗口动画

在WMS中最重要的函数performSurfacePlacementNoTrace()后期,会遍历一块屏幕上所有的窗口(WindowState),如果该WindowState有surfece的话,会调用到WindowState.winAnimator.ommitFinishDrawingLocked()方法:

WindowStateAnimator.java

    boolean commitFinishDrawingLocked() {
        if (DEBUG_STARTING_WINDOW_VERBOSE &&
                mWin.mAttrs.type == WindowManager.LayoutParams.TYPE_APPLICATION_STARTING) {
            Slog.i(TAG, "commitFinishDrawingLocked: " + mWin + " cur mDrawState="
                    + drawStateToString());
        }
        if (mDrawState != COMMIT_DRAW_PENDING && mDrawState != READY_TO_SHOW) {
            return false;
        }
        ProtoLog.i(WM_DEBUG_ANIM, "commitFinishDrawingLocked: mDrawState=READY_TO_SHOW %s",
                mSurfaceController);
        mDrawState = READY_TO_SHOW;
        boolean result = false;
        final ActivityRecord activity = mWin.mActivityRecord;
        if (activity == null || activity.canShowWindows()
                || mWin.mAttrs.type == TYPE_APPLICATION_STARTING) {
            //进一步到这里
            result = mWin.performShowLocked();
        }
        return result;
    }

如果是新添加的窗口则会通过mWinAnimator.applyEnterAnimationLocked();来触发窗口 进入动画。

WindowState.java


    boolean performShowLocked() {

        ......

        logPerformShow("Showing ");

        mWmService.enableScreenIfNeededLocked();
        // 如果新添加进来的窗口,则会在这里触发窗口动画。
        mWinAnimator.applyEnterAnimationLocked();

        // Force the show in the next prepareSurfaceLocked() call.
        mWinAnimator.mLastAlpha = -1;
        ProtoLog.v(WM_DEBUG_ANIM, "performShowLocked: mDrawState=HAS_DRAWN in %s", this);
        mWinAnimator.mDrawState = HAS_DRAWN;
        mWmService.scheduleAnimationLocked();

        if (mHidden) {
            mHidden = false;
            final DisplayContent displayContent = getDisplayContent();

            for (int i = mChildren.size() - 1; i >= 0; --i) {
                final WindowState c = mChildren.get(i);
                if (c.mWinAnimator.mSurfaceController != null) {
                    c.performShowLocked();
                    // It hadn't been shown, which means layout not performed on it, so now we
                    // want to make sure to do a layout.  If called from within the transaction
                    // loop, this will cause it to restart with a new layout.
                    if (displayContent != null) {
                        displayContent.setLayoutNeeded();
                    }
                }
            }
        }

        return true;
    }

如下在窗口动画的处罚过程中if (mAttrType != TYPE_BASE_APPLICATION && !mIsWallpaper) {
applyAnimationLocked(transit, true);
} 就决定了只会给 非Activity主窗口和非壁纸类型的窗口添加窗口进入动画。

WindowStateAnimator.java

void applyEnterAnimationLocked() {
        // If we are the new part of a window replacement transition and we have requested
        // not to animate, we instead want to make it seamless, so we don't want to apply
        // an enter transition.
        if (mWin.mSkipEnterAnimationForSeamlessReplacement) {
            return;
        }

        final int transit;
        // 在窗口的添加过程中 mEnterAnimationPending 已经被置为True,所以transit = WindowManagerPolicy.TRANSIT_ENTER;
        if (mEnterAnimationPending) {
            mEnterAnimationPending = false;
            transit = WindowManagerPolicy.TRANSIT_ENTER;
        } else {
            transit = WindowManagerPolicy.TRANSIT_SHOW;
        }

        // We don't apply animation for application main window here since this window type
        // should be controlled by ActivityRecord in general. Wallpaper is also excluded because
        // WallpaperController should handle it.
        //可以知道窗口进入动画不适用于 非activity的窗口 和 壁纸
        if (mAttrType != TYPE_BASE_APPLICATION && !mIsWallpaper) {
            applyAnimationLocked(transit, true);
        }

        if (mService.mAccessibilityController.hasCallbacks()) {
            mService.mAccessibilityController.onWindowTransition(mWin, transit);
        }
    }

二. 加载对应的动画资源

接下来就是根据场景添加添加对应的窗口动画类型,有定制的话,则采用定制的窗口动画类型。

WindowStateAnimator.java

boolean 
(int transit, boolean isEntrance) {
        if (mWin.isAnimating() && mAnimationIsEntrance == isEntrance) {
            // If we are trying to apply an animation, but already running
            // an animation of the same type, then just leave that one alone.
            return true;
        }

        final boolean isImeWindow = mWin.mAttrs.type == TYPE_INPUT_METHOD;
        // 如果是输入法窗口 特殊处理
        if (isEntrance && isImeWindow) {
            mWin.getDisplayContent().adjustForImeIfNeeded();
            mWin.setDisplayLayoutNeeded();
            mService.mWindowPlacerLocked.requestTraversal();
        }

        // Only apply an animation if the display isn't frozen.  If it is
        // frozen, there is no reason to animate and it can cause strange
        // artifacts when we unfreeze the display if some different animation
        // is running.

        // 正常情况下 mWin.mToken.okToAnimate()) 为true
        if (mWin.mToken.okToAnimate()) {
            // 这个方法中 会对 StatusBar的窗口 和 NavigationBar窗口 进行特殊处理,如果是Toast 则返回DisplayPolicy.ANIMATION_STYLEABLE)
            int anim = mWin.getDisplayContent().getDisplayPolicy().selectAnimation(mWin, transit);
            int attr = -1;
            Animation a = null;
            if (anim != DisplayPolicy.ANIMATION_STYLEABLE) {
                if (anim != DisplayPolicy.ANIMATION_NONE) {
                    Trace.traceBegin(Trace.TRACE_TAG_WINDOW_MANAGER, "WSA#loadAnimation");
                    a = AnimationUtils.loadAnimation(mContext, anim);
                    Trace.traceEnd(Trace.TRACE_TAG_WINDOW_MANAGER);
                }
            } else {
                switch (transit) {
                    // Toast添加会进入到该分支
                    case WindowManagerPolicy.TRANSIT_ENTER:
                        attr = com.android.internal.R.styleable.WindowAnimation_windowEnterAnimation;
                        break;
                    case WindowManagerPolicy.TRANSIT_EXIT:
                        attr = com.android.internal.R.styleable.WindowAnimation_windowExitAnimation;
                        break;
                    case WindowManagerPolicy.TRANSIT_SHOW:
                        attr = com.android.internal.R.styleable.WindowAnimation_windowShowAnimation;
                        break;
                    case WindowManagerPolicy.TRANSIT_HIDE:
                        attr = com.android.internal.R.styleable.WindowAnimation_windowHideAnimation;
                        break;
                }
                if (attr >= 0) {
                    // 根据选择的动画参数 生成 Animation
                    a = mWin.getDisplayContent().mAppTransition.loadAnimationAttr(
                            mWin.mAttrs, attr, TRANSIT_OLD_NONE);
                }
            }
            if (ProtoLogImpl.isEnabled(WM_DEBUG_ANIM)) {
                ProtoLog.v(WM_DEBUG_ANIM, "applyAnimation: win=%s"
                        + " anim=%d attr=0x%x a=%s transit=%d type=%d isEntrance=%b Callers %s",
                        this, anim, attr, a, transit, mAttrType, isEntrance, Debug.getCallers(20));
            }
            if (a != null) {
                Trace.traceBegin(Trace.TRACE_TAG_WINDOW_MANAGER, "WSA#startAnimation");
                // 窗口根据生成的对应类型的Animation ,来进一步触发动画
                mWin.startAnimation(a);
                Trace.traceEnd(Trace.TRACE_TAG_WINDOW_MANAGER);
                mAnimationIsEntrance = isEntrance;
            }
        } else if (!isImeWindow) {
            mWin.cancelAnimation();
        }

        if (!isEntrance && isImeWindow) {
            mWin.getDisplayContent().adjustForImeIfNeeded();
        }

        return mWin.isAnimating(0 /* flags */, ANIMATION_TYPE_WINDOW_ANIMATION);
    }

至此加载对应的动画资源的流程结束。接下来开始进一步触发,如下对动画的参数进行了进一步设置,同时把 WindowAnimationSpec(动画规格)和mWmService.mSurfaceAnimationRunner(用来运行动画)封装到了一个LocalAnimationAdapter对象里

WindowState.java


 void startAnimation(Animation anim) {

        // If we are an inset provider, all our animations are driven by the inset client.
        if (mControllableInsetProvider != null) {
            return;
        }

        final DisplayInfo displayInfo = getDisplayInfo();
        // 对动画的一些参数进行初始化,一些标志位进行初始化
        anim.initialize(mWindowFrames.mFrame.width(), 
        mWindowFrames.mFrame.height(),
                displayInfo.appWidth, displayInfo.appHeight);
        // 给动画设置duiration
        anim.restrictDuration(MAX_ANIMATION_DURATION);
        // 设置动画缩放比例
        anim.scaleCurrentDuration(mWmService.getWindowAnimationScaleLocked());
        // 在这里创建了一个 LocalAnimationAdapter 对象
        final AnimationAdapter adapter = new LocalAnimationAdapter(
                new WindowAnimationSpec(anim, mSurfacePosition, false /* canSkipFirstFrame */,
                        0 /* windowCornerRadius */),
                mWmService.mSurfaceAnimationRunner);
        // 进一步启动动画
        startAnimation(getPendingTransaction(), adapter);
        commitPendingTransaction();
    }



 private void startAnimation(Transaction t, AnimationAdapter adapter) {
        startAnimation(t, adapter, mWinAnimator.mLastHidden, ANIMATION_TYPE_WINDOW_ANIMATION);
 }

 void startAnimation(Transaction t, AnimationAdapter anim, boolean hidden,
            @AnimationType int type) {
        startAnimation(t, anim, hidden, type, null /* animationFinishedCallback */);
    }

void startAnimation(Transaction t, AnimationAdapter anim, boolean hidden,
            @AnimationType int type) {
        startAnimation(t, anim, hidden, type, null /* animationFinishedCallback */);
    }

void startAnimation(Transaction t, AnimationAdapter anim, boolean hidden,
            @AnimationType int type,
            @Nullable OnAnimationFinishedCallback animationFinishedCallback) {
        startAnimation(t, anim, hidden, type, animationFinishedCallback,
                null /* adapterAnimationCancelledCallback */, null /* snapshotAnim */);
    }

三. 创建leash,同时把执行动画的窗口容器reparent到 leash上

如下调用到mSurfaceAnimator.startAnimation()来进一步启动动画。
但是需要注意的是 mSurfaceAnimator,为WindowContainer的一个属性, 类型为SurfaceAnimator,以下为SurfaceAnimator的类注释:

/**
 * 一个可以运行动画在一些拥有子surfaces的 对象上,
 * A class that can run animations on objects that have a set of child surfaces. We do this by
 * 
 * reparenting all child surfaces of an object onto a new surface, called the "Leash". The Leash
 * gets attached in the surface hierarchy where the the children were attached to. We then hand off
 * the Leash to the component handling the animation, which is specified by the
 * {@link AnimationAdapter}. When the animation is done animating, our callback to finish the
 * animation will be invoked, at which we reparent the children back to the original parent.
 */
class SurfaceAnimator {
    ......
}

SurfaceAnimator引入了一个重要的概念:Leash,字面翻译为 “系带“,何为Leash?

WindowContainer.java

void startAnimation(Transaction t, AnimationAdapter anim, boolean hidden,
            @AnimationType int type,
            @Nullable OnAnimationFinishedCallback animationFinishedCallback,
            @Nullable Runnable animationCancelledCallback,
            @Nullable AnimationAdapter snapshotAnim) {
        ProtoLog.v(WM_DEBUG_ANIM, "Starting animation on %s: type=%d, anim=%s",
                this, type, anim);

        // TODO: This should use isVisible() but because isVisible has a really weird meaning at
        // the moment this doesn't work for all animatable window containers.
        mSurfaceAnimator.startAnimation(t, anim, hidden, type, animationFinishedCallback,
                animationCancelledCallback, snapshotAnim, mSurfaceFreezer);
    }
SurfaceAnimator.java

void startAnimation(Transaction t, AnimationAdapter anim, boolean hidden,
            @AnimationType int type,
            @Nullable OnAnimationFinishedCallback animationFinishedCallback,
            @Nullable Runnable animationCancelledCallback,
            @Nullable AnimationAdapter snapshotAnim, @Nullable SurfaceFreezer freezer) {
        cancelAnimation(t, true /* restarting */, true /* forwardCancel */);
        mAnimation = anim;
        mAnimationType = type;
        mSurfaceAnimationFinishedCallback = animationFinishedCallback;
        mAnimationCancelledCallback = animationCancelledCallback;
        final SurfaceControl surface = mAnimatable.getSurfaceControl();
        if (surface == null) {
            Slog.w(TAG, "Unable to start animation, surface is null or no children.");
            cancelAnimation();
            return;
        }
        mLeash = freezer != null ? freezer.takeLeashForAnimation() : null;
        // 在这里会创建leash
        if (mLeash == null) {
            mLeash = createAnimationLeash(mAnimatable, surface, t, type,
                    mAnimatable.getSurfaceWidth(), mAnimatable.getSurfaceHeight(), 0 /* x */,
                    0 /* y */, hidden, mService.mTransactionFactory);
            mAnimatable.onAnimationLeashCreated(t, mLeash);
        }
        mAnimatable.onLeashAnimationStarting(t, mLeash);
        if (mAnimationStartDelayed) {
            ProtoLog.i(WM_DEBUG_ANIM, "Animation start delayed for %s", mAnimatable);
            return;
        }
        // 创建完毕Leash 开始进一步的启动动画
        mAnimation.startAnimation(mLeash, t, type, mInnerAnimationFinishedCallback);
        if (ProtoLogImpl.isEnabled(WM_DEBUG_ANIM)) {
            StringWriter sw = new StringWriter();
            PrintWriter pw = new PrintWriter(sw);
            mAnimation.dump(pw, "");
            ProtoLog.d(WM_DEBUG_ANIM, "Animation start for %s, anim=%s", mAnimatable, sw);
        }
        if (snapshotAnim != null) {
            mSnapshot = freezer.takeSnapshotForAnimation();
            if (mSnapshot == null) {
                Slog.e(TAG, "No snapshot target to start animation on for " + mAnimatable);
                return;
            }
            mSnapshot.startAnimation(t, snapshotAnim, type);
        }
    }

如上,当startAnimation的mLeash为空的时候会去创建一个Leash,重点关注createAnimationLeash(),了解Leash是如何创建的。

SurfaceAnimator.java

  static SurfaceControl createAnimationLeash(Animatable animatable, SurfaceControl surface,
            Transaction t, @AnimationType int type, int width, int height, int x, int y,
            boolean hidden, Supplier<Transaction> transactionFactory) {
        ProtoLog.i(WM_DEBUG_ANIM, "Reparenting to leash for %s", animatable);
        final SurfaceControl.Builder builder = animatable.makeAnimationLeash()
                // 设置Leash的父节点为运行窗口动画的目标对象的SurfaceCrol的父节点
                .setParent(animatable.getAnimationLeashParent())
                .setName(surface + " - animation-leash of " + animationTypeToString(type))
                // TODO(b/151665759) Defer reparent calls
                // We want the leash to be visible immediately because the transaction which shows
                // the leash may be deferred but the reparent will not. This will cause the leashed
                // surface to be invisible until the deferred transaction is applied. If this
                // doesn't work, you will can see the 2/3 button nav bar flicker during seamless
                // rotation.
                .setHidden(hidden)
                // 设置Leash的Surface类型为容器类型,即没有有用于渲染的Buffer。
                .setEffectLayer()
                .setCallsite("SurfaceAnimator.createAnimationLeash");
        final SurfaceControl leash = builder.build();
        t.setWindowCrop(leash, width, height);
        t.setPosition(leash, x, y);
        t.show(leash);
        t.setAlpha(leash, hidden ? 0 : 1);
        // 把需要执行动画的SurafaControl又挂在Leash上
        t.reparent(surface, leash);
        return leash;
    }

以上为Leah的创过程,其中最重要的就是三步:

  • 创建Leash(本质上就是个SurfaceControl),设置Leash的父节点为运行窗口动画的目标对象SurfaceCrol的父节点。
  • 设置Leash的Surface类型为容器类型
  • 把运行窗口动画的目标对象SurfaceCrol的父节点又设置为Leash。

简而言之,就是在执行动画的SuraceCntrol和它的父节点之间添加了一个类型为Leash的SurfaceControl,后面执行动画就直接操作这个Leash即可。

创建完毕Leash接下来通过 mAnimation.startAnimation(mLeash, t, type, mInnerAnimationFinishedCallback);来进一步启动动画。

LocalAnimationAdapter.java

@Override
    public void startAnimation(SurfaceControl animationLeash, Transaction t,
            @AnimationType int type, @NonNull OnAnimationFinishedCallback finishCallback) {
        mAnimator.startAnimation(mSpec, animationLeash, t,
                () -> finishCallback.onAnimationFinished(type, this));
    }

四.开始循环执行窗口动画

    void startAnimation(AnimationSpec a, SurfaceControl animationLeash, Transaction t,
            Runnable finishCallback) {
        synchronized (mLock) {
            final RunningAnimation runningAnim = new RunningAnimation(a, animationLeash,
                    finishCallback);
            boolean requiresEdgeExtension = requiresEdgeExtension(a);

            ... ...

            if (!requiresEdgeExtension) {
                mPendingAnimations.put(animationLeash, runningAnim);
                if (!mAnimationStartDeferred && mPreProcessingAnimations.isEmpty()) {
                    mChoreographer.postFrameCallback(this::startAnimations);
                }

                // Some animations (e.g. move animations) require the initial transform to be
                // applied immediately.
                applyTransformation(runningAnim, t, 0 /* currentPlayTime */);
            }
        }
    }

        private void startAnimations(long frameTimeNanos) {
        synchronized (mLock) {
            if (!mPreProcessingAnimations.isEmpty()) {
                // We only want to start running animations once all mPreProcessingAnimations have
                // been processed to ensure preprocessed animations start in sync.
                // NOTE: This means we might delay running animations that require preprocessing if
                // new animations that also require preprocessing are requested before the previous
                // ones have finished (see b/227449117).
                return;
            }
            // 近一步启动
            startPendingAnimationsLocked();
        }
        mPowerManagerInternal.setPowerBoost(Boost.INTERACTION, 0);
    }
   @GuardedBy("mLock")
    private void startAnimationLocked(RunningAnimation a) {
        // 注意在这里创建了一个ValueAnimator的对象,后续动画的运行就要又它来处理
        final ValueAnimator anim = mAnimatorFactory.makeAnimator();

        // Animation length is already expected to be scaled.
        // 设置缩放比例
        anim.overrideDurationScale(1.0f);
        // 设置持续时间
        anim.setDuration(a.mAnimSpec.getDuration());
        // 注册更新的监听,将会由ValueAnimator 不断触发以实现动画的效果、
        anim.addUpdateListener(animation -> {
            synchronized (mCancelLock) {
                if (!a.mCancelled) {
                    final long duration = anim.getDuration();
                    long currentPlayTime = anim.getCurrentPlayTime();
                    if (currentPlayTime > duration) {
                        currentPlayTime = duration;
                    }
                    // 在这里会根据duration来真正操作surafce的属性,以实现窗口动画的效果
                    applyTransformation(a, mFrameTransaction, currentPlayTime);
                }
            }

            // Transaction will be applied in the commit phase.
            // 对surface属性的修改并不能立马生效,需要合并成一个事务,然后提交给surafeflinger。
            scheduleApplyTransaction();
        });
    
        anim.addListener(new AnimatorListenerAdapter() {
            // 添加动画开始的监听
            @Override
            public void onAnimationStart(Animator animation) {
                synchronized (mCancelLock) {
                    if (!a.mCancelled) {
                        // TODO: change this back to use show instead of alpha when b/138459974 is
                        // fixed.
                        mFrameTransaction.setAlpha(a.mLeash, 1);
                    }
                }
            }

            // 添加动画结束的监听
            @Override
            public void onAnimationEnd(Animator animation) {
                synchronized (mLock) {
                    mRunningAnimations.remove(a.mLeash);
                    synchronized (mCancelLock) {
                        if (!a.mCancelled) {

                            // Post on other thread that we can push final state without jank.
                            mAnimationThreadHandler.post(a.mFinishCallback);
                        }
                    }
                }
            }
        });
        a.mAnim = anim;
        // 把正在运行的动画添加到mRunningAnimations中
        mRunningAnimations.put(a.mLeash, a);

        // 在这里会真正的开始启动动画。
        anim.start();
        if (a.mAnimSpec.canSkipFirstFrame()) {
            // If we can skip the first frame, we start one frame later.
            anim.setCurrentPlayTime(mChoreographer.getFrameIntervalNanos() / NANOS_PER_MS);
        }

        // Immediately start the animation by manually applying an animation frame. Otherwise, the
        // start time would only be set in the next frame, leading to a delay.
        anim.doAnimationFrame(mChoreographer.getFrameTime());
    }

可见最后驱动窗口执行动画是属性动画,但是这个属性动画比较特的是他的编舞者是Wms中专门用于系统动画的一个编舞者。

属性动画的驱动原理,这篇文章不重点介绍,

  • 2
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
Android提供了一些默认的窗口切换动画,可以在Activity之间切换时使用,也可以自定义窗口切换动画。以下是几种常用的窗口切换动画: 1. 淡入淡出动画: 在res/anim文件夹下创建两个xml文件:fade_in.xml和fade_out.xml fade_in.xml: ```xml <?xml version="1.0" encoding="utf-8"?> <alpha xmlns:android="http://schemas.android.com/apk/res/android" android:duration="300" android:fromAlpha="0.0" android:toAlpha="1.0" /> ``` fade_out.xml: ```xml <?xml version="1.0" encoding="utf-8"?> <alpha xmlns:android="http://schemas.android.com/apk/res/android" android:duration="300" android:fromAlpha="1.0" android:toAlpha="0.0" /> ``` 在Activity中使用: ```java Intent intent = new Intent(this, SecondActivity.class); startActivity(intent); overridePendingTransition(R.anim.fade_in, R.anim.fade_out); ``` 2. 滑动动画: 在res/anim文件夹下创建两个xml文件:slide_in_right.xml和slide_out_left.xml slide_in_right.xml: ```xml <?xml version="1.0" encoding="utf-8"?> <set xmlns:android="http://schemas.android.com/apk/res/android"> <translate android:duration="300" android:fromXDelta="100%" android:toXDelta="0%" /> </set> ``` slide_out_left.xml: ```xml <?xml version="1.0" encoding="utf-8"?> <set xmlns:android="http://schemas.android.com/apk/res/android"> <translate android:duration="300" android:fromXDelta="0%" android:toXDelta="-100%" /> </set> ``` 在Activity中使用: ```java Intent intent = new Intent(this, SecondActivity.class); startActivity(intent); overridePendingTransition(R.anim.slide_in_right, R.anim.slide_out_left); ``` 3. 缩放动画: 在res/anim文件夹下创建两个xml文件:zoom_in.xml和zoom_out.xml zoom_in.xml: ```xml <?xml version="1.0" encoding="utf-8"?> <set xmlns:android="http://schemas.android.com/apk/res/android"> <scale android:duration="300" android:fromXScale="0.0" android:fromYScale="0.0" android:pivotX="50%" android:pivotY="50%" android:toXScale="1.0" android:toYScale="1.0" /> </set> ``` zoom_out.xml: ```xml <?xml version="1.0" encoding="utf-8"?> <set xmlns:android="http://schemas.android.com/apk/res/android"> <scale android:duration="300" android:fromXScale="1.0" android:fromYScale="1.0" android:pivotX="50%" android:pivotY="50%" android:toXScale="0.0" android:toYScale="0.0" /> </set> ``` 在Activity中使用: ```java Intent intent = new Intent(this, SecondActivity.class); startActivity(intent); overridePendingTransition(R.anim.zoom_in, R.anim.zoom_out); ``` 以上是几种常用的窗口切换动画,可以根据需要选择适合的动画效果。
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值