前言
前些天看到这个效果图
[改装加强版,改进了圆入框的甩尾效果,最重要的一点是
增强ViewPager切换效果和卡片阴影]
集成方式【伸手党福利】
github地址 : github.com/qdxxxx/Bezi…
多谢老铁随手就是一个star,抱拳。
[标题党一般是: 转疯了,项目集成此酷炫动画只要3步!]
- 注入依赖
Step 1. Add the JitPack repository to your build file
Step 2. Add the dependency
allprojects {
repositories {
...
maven { url 'https://jitpack.io' }
}
}
dependencies {
compile 'com.github.qdxxxx:BezierViewPager:v1.0.5'
}
- xml布局代码
<qdx.bezierviewpager_compile.vPage.BezierViewPager
android:id="@+id/view_page"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<qdx.bezierviewpager_compile.BezierRoundView
android:id="@+id/bezRound"
android:layout_width="match_parent"
android:layout_height="wrap_content"
/>
- Activity里面集成代码
CardPagerAdapter cardAdapter = new CardPagerAdapter(getApplicationContext());
cardAdapter.addImgUrlList(imgList); //放置图片url的list
BezierViewPager viewPager = (BezierViewPager) findViewById(R.id.view_page);
viewPager.setAdapter(cardAdapter);
BezierRoundView bezRound = (BezierRoundView) findViewById(R.id.bezRound);
bezRound.attach2ViewPage(viewPager);
方法及属性介绍
- BezierRoundView
name | format | 中文解释 |
---|---|---|
color_bez | color | 贝塞尔圆球颜色 |
color_touch | color | 触摸反馈 |
color_stroke | color | 圆框的颜色 |
time_animator | integer | 动画时间 |
round_count | integer | 圆框数量,即Adapter.getCount |
radius | dimension | 贝塞尔圆球半径,圆框半径为(radius-2) |
attach2ViewPage | BezierViewPager | 绑定指定的ViewPager(处理滑动时触摸事件) 并自动设置round_count |
- BezierViewPager[extends ViewPager]
name | format | 中文解释 |
---|---|---|
showTransformer | float | ViewPager滑动到当前显示页的放大比例 |
- CardPagerAdapter[extends PagerAdapter]
name | format | 中文解释 |
---|---|---|
addImgUrlList | List | 包含图片地址的list |
setOnCardItemClickListener | OnCardItemClickListener | 当前ViewPager点击事件 返回CurPosition |
setMaxElevationFactor | integer | Adapter里CardView最大的Elevation |
实现解剖
private PointF p0, p1, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11;
p0 = new PointF(0, -mRadius);//mRadius圆的半径
p6 = new PointF(0, mRadius);
p1 = new PointF(mRadius * bezFactor, -mRadius);//bezFactor即0.5519...
p5 = new PointF(mRadius * bezFactor, mRadius);
p2 = new PointF(mRadius, -mRadius * bezFactor);
p4 = new PointF(mRadius, mRadius * bezFactor);
p3 = new PointF(mRadius, 0);
p9 = new PointF(-mRadius, 0);
p11 = new PointF(-mRadius * bezFactor, -mRadius);
p7 = new PointF(-mRadius * bezFactor, mRadius);
p10 = new PointF(-mRadius, -mRadius * bezFactor);
p8 = new PointF(-mRadius, mRadius * bezFactor);
再绘制path
mPath.moveTo(p0.x, p0.y);
mPath.cubicTo(p1.x, p1.y, p2.x, p2.y, p3.x, p3.y);
mPath.cubicTo(p4.x, p4.y, p5.x, p5.y, p6.x, p6.y);
mPath.cubicTo(p7.x, p7.y, p8.x, p8.y, p9.x, p9.y);
mPath.cubicTo(p10.x, p10.y, p11.x, p11.y, p0.x, p0.y);
mPath.close();
一个贝(ri)塞(ben)尔(guo)圆(qi)栩栩如生。
我们尝试通过手指滑动改变,p2,p3,p4的x轴坐标来观察圆的变化
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_MOVE:
case MotionEvent.ACTION_DOWN:
p2 = new PointF(event.getX() - mWidth / 2, -mRadius * bezFactor);
p3 = new PointF(event.getX() - mWidth / 2, 0);
p4 = new PointF(event.getX() - mWidth / 2, mRadius * bezFactor);
invalidate();
break;
}
return true;
}
2.解剖效果图
首先我们不考虑反弹效果,圆的变化有3种状态
- bezier圆还没离开圆框,p2,3,4 x轴坐标由 r , 变化至 2r。
- bezier圆离开圆框,至到达中心位置
[p2,3,4 x轴坐标由 2r 变化至 1.5r ],[p8,9,10 x轴坐标由 r 变化至 1.5r ] - bezier圆由中心位置,至到达下一个圆框。
[p2,3,4, 8,9,10 x轴坐标由 1.5r 变化至 r ]
老样子,我们用ValueAnimator来模拟一下[0,1]变化的值。【因为ViewPager的onPageScrolled监听中positionOffset是[0,1)变化的,类似。】
//展示动画
private ValueAnimator animatorStart;
private TimeInterpolator timeInterpolator = new DecelerateInterpolator();
private float animatedValue; //[0,1]的值
public void startAnimator() {
if (animatorStart != null) {
if (animatorStart.isRunning()) {
return;
}
animatorStart.start();
} else {
animatorStart = ValueAnimator.ofFloat(0, 1f).setDuration(1500);
animatorStart.setInterpolator(timeInterpolator);
animatorStart.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
animatedValue = (float) animation.getAnimatedValue();
invalidate();
}
});
animatorStart.start();
}
}
private float rRadio=1; //P2,3,4 x轴倍数
private float lRadio=1; //P8,9,10倍数
private float tbRadio=1; //y轴缩放倍数
private float disL = 0.5f; //离开圆的阈值
private float disM = 0.8f; //最大值的阈值
private float disA = 0.9f; //到达下个圆框的阈值
if (0 < animatedValue && animatedValue <= disL) { //还没离开圆框的时候
rRadio = 1f + animatedValue * 2; //[1,2]
}
if (disL < animatedValue && animatedValue <= disM) {//离开圆框,至最大值区域
rRadio = 2 - range0Until1(disL, disM) * 0.5f; // [2,1.5]
lRadio = 1 + range0Until1(disL, disM) * 0.5f; // [1,1.5]
}
if (disM < animatedValue && animatedValue <= disA) { //从最大值,至到达下一个圆框
rRadio = 1.5f - range0Until1(disM, disA) * 0.5f; // [1.5,1]
lRadio = 1.5f - range0Until1(disM, disA) * 0.5f; // [1.5,1]
}
/** * 将值域转化为[0,1] * * @param minValue 大于等于 * @param maxValue 小于等于 * @return 根据当前 animatedValue,返回 [0,1] 对应的数值 */
private float range0Until1(float minValue, float maxValue) {
return (animatedValue - minValue) / (maxValue - minValue);
}
请再次原谅我用这么简单粗暴的方式画圆…
mPath.moveTo(p0.x, p0.y * tbRadio);
mPath.cubicTo(p1.x, p1.y * tbRadio, p2.x * rRadio, p2.y, p3.x * rRadio, p3.y);
mPath.cubicTo(p4.x * rRadio, p4.y, p5.x, p5.y * tbRadio, p6.x, p6.y * tbRadio);
mPath.cubicTo(p7.x, p7.y * tbRadio, p8.x * lRadio, p8.y, p9.x * lRadio, p9.y);
mPath.cubicTo(p10.x * lRadio, p10.y, p11.x, p11.y * tbRadio, p0.x, p0.y * tbRadio);
mPath.close();
理清了上面这些代码,一个有灵性的贝塞尔圆就即将绘制成功。我们再加上离开圆至到达下一个圆框这个区域y轴变化,[p,5,6,7, 1,0,11],效果就如下所示。
3.模拟效果
这时候我们已经将贝塞尔圆的运动方式给表达出来了,再加上一些效果[位移/反弹/翻转],我们就能模拟出贝塞尔圆从一个圆框进入下一个圆框的动画了。
在上面的基础上,我们加上反弹效果
if (0 < animatedValue && animatedValue <= disL) { //还没离开圆框的时候
rRadio = 1f + animatedValue * 2; //[1,2]
}
if (disL < animatedValue && animatedValue <= disM) {//离开圆框,至最大值区域
rRadio = 2 - range0Until1(disL, disM) * 0.5f; // [2,1.5]
lRadio = 1 + range0Until1(disL, disM) * 0.5f; // [1,1.5]
tbRadio = 1 - range0Until1(disL, disM) / 3; // [1 , 2/3]
}
if (disM < animatedValue && animatedValue <= disA) { //从最大值,至到达下一个圆框
rRadio = 1.5f - range0Until1(disM, disA) * 0.5f; // [1.5,1]
lRadio = 1.5f - range0Until1(disM, disA) * (1.5f - boundRadio); //反弹效果,进场 内弹boundRadio lRadio =[1.5,boundRadio]
tbRadio = (range0Until1(disM, disA) + 2) / 3; // [ 2/3,1]
}
if (disA < animatedValue && animatedValue <= 1f) {//到达圆框,lRadio=[boundRadio,1]
rRadio = 1;
tbRadio = 1;
lRadio = boundRadio + range0Until1(disA, 1) * (1 - boundRadio); //反弹效果,饱和
}
再加上位移效果。一开始我在想,贝塞尔圆要不断的变化形态,还要移动位置。岂不相当的麻烦。后来把它分解成变化状态+不断位移效果。
boolean isTrans = false;
float transX = 1f;
if (disL <= animatedValue && animatedValue <= disA) { //离开圆框,至到达下一个圆框
isTrans = true;
//我们设置2个圆框距离为mWidth / 2f
transX = mWidth / 2f * range0Until1(disL, disA); //[0,mWidth / 2f]
}
if (disA < animatedValue && animatedValue <= 1) {//到达下一个圆
isTrans = true;
transX = mWidth / 2;
}
if (isTrans) {
canvas.translate(transX, 0);
}
至此贝塞尔圆球进入右侧圆框的效果已经实现,那么如果圆球要从右侧圆框进入左侧圆框呢?
【题外话:写完上面这个效果已经是月黑风高的时候了,脑神经即将进入假死状态,我心想,虽然复杂了点,但是应该还是可以做的出来的,脑袋运行的速度根本跟不上敲代码的速度。根据位移方向的判断从而设定lRadio和rRadio。有点自信回头的赶脚。。。休息了一觉第二天醒来天啊噜,为什么不用Matrix,只要用path.transform(matrix),就可以做到镜像path,所以适当的休息有助于提升效率。】
matrix_bounceL = new Matrix();
matrix_bounceL.preScale(-1, 1);
mPath.transform(matrix_bounceL);
4.Attach2ViewPager
关联ViewPager总共有2个要点
- ViewPager的滑动监听,onPageScrolled。
根据positionOffset和position,获取我们所要的当前位置/下一个位置/移动方向。 - 手动选择ViewPager,即手指点击非当前圆框。
4.1 onPageScrolled
首先我们来了解一下onPageScrolled
这个方法中2个我们要用到的参数
- position : 当前cur位置,如果当前是1,手指按住右滑(vPage向左滑动)那就立马变为0。但如果当前是1,手指按住要左滑至下一个位置才为2
- positionOffset : [0,1) ,到达下一个pos就置为0
我们功能需求分析一下:
- 获取正确的当前位置curPos
- 获取正确的贝塞尔球进入的下一个位置nextPos
- 获取正确的贝塞尔球运动方向
- 配置正确的animatedValue
之前我们用ValueAnimator
来模拟运动状态,现在我们可以使用positionOffset
关联到ViewPager
animatedValue = positionOffset;
direction = ((position + positionOffset) - curPos > 0); //运动方向。 true为右边(手往左滑动)
nextPos = direction ? curPos + 1 : curPos - 1; //右 +1 左 -1
if (!direction) //如果是向左
animatedValue = 1 - animatedValue; //让 animatedValue 不管是左滑还是右滑,都从[0,1)开始计算
if (positionOffset == 0) {
curPos = position;
nextPos = position;
}
以上代码还需动手调试,看看log才能更明白的领悟。
从上面的gif可以发现如果缓慢的滑动,pos的位置正确的,但是如果快速滑动,就会发现问题 : [例如0快速滑动到2,贝塞尔圆球会从0滑动到1,再从0滑动到2],打了Log之后我们才发现原来快速滑动的时候,positionOffset到达下一个pos不会置为0!!发现问题后就好解决了。我们加上这一段代码就可以解决该问题。(快速滑动可能存在或多或少的问题,我也是花了些时间去测试的。)
//快速滑动的时候,positionOffset有可能不会置于0
if (direction && position + positionOffset > nextPos) { //向右,而且
curPos = position;
nextPos = position + 1;
} else if (!direction && position + positionOffset < nextPos) {
curPos = position;
nextPos = position - 1;
}
onDraw
我们先要获得每个圆框的圆心x轴坐标
private float[] bezPos; //记录每一个圆心x轴的位置
bezPos = new float[default_round_count]; //根据圆框个数
for (int i = 0; i < default_round_count; i++) {
bezPos[i] = mWidth / (default_round_count + 1) * (i + 1);
}
假设我们的default_round_count 即圆框个数为4,那么我们就要分成 4+1 份,再综合上述的求圆心代码,应该会更清晰一点。
根据curPos和nextPos绘制贝塞尔圆球,po出onDraw代码
canvas.translate(0, mHeight / 2);
mBezPath.reset();
for (int i = 0; i < default_round_count; i++) {
canvas.drawCircle(bezPos[i], 0, mRadius - 2, mRoundStrokePaint); //绘制圆框
}
if (animatedValue == 1) {
canvas.drawCircle(bezPos[nextPos], 0, mRadius, mBezPaint);
return;
}
canvas.translate(bezPos[curPos], 0); //根据curPos,移动到当前圆框位置
if (0 < animatedValue && animatedValue <= disL) {
rRadio = 1f + animatedValue * 2; // [1,2]
lRadio = 1f;
tbRadio = 1f;
}
if (disL < animatedValue && animatedValue <= disM) {
rRadio = 2 - range0Until1(disL, disM) * 0.5f; // [2,1.5]
lRadio = 1 + range0Until1(disL, disM) * 0.5f; // [1,1.5]
tbRadio = 1 - range0Until1(disL, disM) / 3; // [1 , 2/3]
}
if (disM < animatedValue && animatedValue <= disA) {
rRadio = 1.5f - range0Until1(disM, disA) * 0.5f; // [1.5,1]
lRadio = 1.5f - range0Until1(disM, disA) * (1.5f - boundRadio); //反弹效果,进场 内弹boundRadio
tbRadio = (range0Until1(disM, disA) + 2) / 3; // [ 2/3,1]
}
if (disA < animatedValue && animatedValue <= 1f) {
rRadio = 1;
tbRadio = 1;
lRadio = boundRadio + range0Until1(disA, 1) * (1 - boundRadio); //反弹效果,饱和
}
if (animatedValue == 1 || animatedValue == 0) { //防止极其粗暴的滑动
rRadio = 1f;
lRadio = 1f;
tbRadio = 1f;
}
boolean isTrans = false; //根据nextPos和curPos求出位移距离
float transX = (nextPos - curPos) * (mWidth / (default_round_count + 1));
if (disL <= animatedValue && animatedValue <= disA) {
isTrans = true;
transX = transX * (animatedValue - disL) / (disA - disL);
}
if (disA < animatedValue && animatedValue <= 1) {
isTrans = true;
}
if (isTrans) {
canvas.translate(transX, 0);
}
mBezPath.moveTo(p0.x, p0.y * tbRadio);
mBezPath.cubicTo(p1.x, p1.y * tbRadio, p2.x * rRadio, p2.y, p3.x * rRadio, p3.y);
mBezPath.cubicTo(p4.x * rRadio, p4.y, p5.x, p5.y * tbRadio, p6.x, p6.y * tbRadio);
mBezPath.cubicTo(p7.x, p7.y * tbRadio, p8.x * lRadio, p8.y, p9.x * lRadio, p9.y);
mBezPath.cubicTo(p10.x * lRadio, p10.y, p11.x, p11.y * tbRadio, p0.x, p0.y * tbRadio);
mBezPath.close();
if (!direction) {
mBezPath.transform(matrix_bounceL);
}
canvas.drawPath(mBezPath, mBezPaint);
if (isTrans) {
canvas.save();
}
4.2 点击圆框,设置ViewPager的curItem
我们需要判断是否点击到了圆框上,和点击了具体哪个圆框。
在onPageScrolled
方法的时候不进行处理,而是通过ValueAnimator
来模拟数值。从而绘制贝塞尔圆球效果。
private float[] xPivotPos; //根据圆心x轴+mRadius,划分成不同的区域 ,主要为了判断触摸x轴的位置
xPivotPos = new float[default_round_count];
for (int i = 0; i < default_round_count; i++) {
xPivotPos[i] = mWidth / (default_round_count + 1) * (i + 1) + mRadius;
}
针对x轴 : 我的做法是用一个数组xPivotPos 存储每个圆框最边缘的位置,即圆心+mRadius,然后我们触摸的时候,就可以找到当前触摸touchPos是属于哪个(圆框+mRadius)范围内。只要x >=bezPos[touchPos]-mRadius,就可以清楚的知道是否触摸到了该区域的圆框范围。
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
float x = event.getX();
float y = event.getY();
if (y <= mHeight / 2 + mRadius && y >= mHeight / 2 - mRadius && !isAniming) { //先判断y,如果y点击是在圆y轴的范围
int pos = -Arrays.binarySearch(xPivotPos, x) - 1;
if (pos >= 0 && pos < default_round_count && x + mRadius >= bezPos[pos]) {
nextPos = pos;
if (mViewPage != null && curPos != nextPos) {
mViewPage.setCurrentItem(pos);
isAniming = true;
direction = (curPos < pos);
startAnimator(); //我们通过ValueAnimator来模拟具体的值,不使用ViewPager的onPageScrolled方法。
}
}
return true;
}
break;
}
return super.onTouchEvent(event);
}
至此我们BezierRoundView的用法和绘制方法已经讲解完了,下面来看一下ViewPager是怎么实现切换效果的。
实现ViewPager切换效果
setClipToPadding
【灵魂画家】
上图针对的是ViewPager设置Padding之后,
左图是正常情况下默认 setClipToPadding(true) 的显示情况,设置Padding之后,手机屏幕上只显示width-PaddingLeft – PaddingRight。
setMaxCardElevation
CardPagerAdapter是我们继承PagerAdapter
的类,adapter里的布局是cardView
<android.support.v7.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/cardView"
app:cardCornerRadius="10dp"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:cardPreventCornerOverlap="true"
app:cardUseCompatPadding="true">
<!--cardUseCompatPadding 设置阴影之后自动缩小布局大小-->
<ImageView
android:id="@+id/item_iv"
android:scaleType="fitXY"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</android.support.v7.widget.CardView>
先来了解一下cardView setCardElevation(float)
方法。【针对CardViewApi21】
if (!cardView.getUseCompatPadding()) {
cardView.setShadowPadding(0, 0, 0, 0);
return;
}
float elevation = getMaxElevation(cardView);
final float radius = getRadius(cardView);
int hPadding = (int) Math.ceil(RoundRectDrawableWithShadow
.calculateHorizontalPadding(elevation, radius, cardView.getPreventCornerOverlap()));
int vPadding = (int) Math.ceil(RoundRectDrawableWithShadow
.calculateVerticalPadding(elevation, radius, cardView.getPreventCornerOverlap()));
cardView.setShadowPadding(hPadding, vPadding, hPadding, vPadding);
static float calculateVerticalPadding(float maxShadowSize, float cornerRadius, boolean addPaddingForCorners) {
if (addPaddingForCorners) {
return (float) (maxShadowSize * SHADOW_MULTIPLIER + (1 - COS_45) * cornerRadius);
} else {
return maxShadowSize * SHADOW_MULTIPLIER;
}
}
static float calculateHorizontalPadding(float maxShadowSize, float cornerRadius, boolean addPaddingForCorners) {
if (addPaddingForCorners) {
return (float) (maxShadowSize + (1 - COS_45) * cornerRadius);
} else {
return maxShadowSize;
}
}
下面看一下效果测试。
ViewPager效果测试
我们来看一下ViewPager左右设置Padding为mWidth / 10的效果
viewPager.setPadding(mWidth / 10, 0, mWidth / 10, 0);
viewPager.setClipToPadding(false);
再来看一下CardPagerAdapter设置MaxElevationFactor为mWidth / 10的效果【adapter.xml的cardCornerRadius不设值,cardUseCompatPadding一定要设置true!!】
int maxFactor = mWidth / 10;
cardAdapter.setMaxElevationFactor(maxFactor);
具体我也不赘述了,看图应该能分析出两者的不同。
所以现在综上所述,制定一个需求
- 不管是设置padding还是Elevation都要保持图片的宽高比例。
也就是说当我们知道图片的宽高比例之后,代码里面我们要动态的去调整和设置并保持这个宽高比例。
【这边有个坑就是设置setMaxElevation它的宽高比是不可抗的,所以我们只能在setPadding的时候,去调节这个比例】
【setMaxElevation
宽的Padding为maxFactor + 0.3*CornerRadius 【0.3≈≈ (1 – COS_45)】
高的Padding为maxFactor*1.5f + 0.3*CornerRadius】
但是!
如果我们在
setMaxElevation
的情况下,在去设置padding,那么如何保证我们的宽高比?具体请看如下代码分析。【可以通过去掉adapter.xml 里ImagerView 的android:scaleType=”fitXY”属性测试一下宽高比例是否调试正确
】
//已知图片的宽为1920,高1080.
int mWidth = getWindowManager().getDefaultDisplay().getWidth();
float heightRatio = 0.565f; //高是宽的 0.565 ,根据图片比例
CardPagerAdapter cardAdapter = new CardPagerAdapter(getApplicationContext());
cardAdapter.addImgUrlList(imgList);//添加加载的图片集合
//设置阴影大小,即vPage 左右两个图片相距边框 maxFactor + 0.3*CornerRadius *2
//设置阴影大小,即vPage 上下图片相距边框 maxFactor*1.5f + 0.3*CornerRadius
int maxFactor = mWidth / 25;
cardAdapter.setMaxElevationFactor(maxFactor);
int mWidthPading = mWidth / 8;
//因为我们adapter里的cardView CornerRadius已经写死为10dp,所以0.3*CornerRadius=3
//设置Elevation之后,控件宽度要减去 (maxFactor + dp2px(3)) * heightRatio
//heightMore 设置Elevation之后,控件高度 比 控件宽度* heightRatio 多出的部分
float heightMore = (1.5f * maxFactor + dp2px(3)) - (maxFactor + dp2px(3)) * heightRatio;
int mHeightPading = (int) (mWidthPading * heightRatio - heightMore);
BezierViewPager viewPager = (BezierViewPager) findViewById(R.id.view_page);
viewPager.setLayoutParams(new RelativeLayout.LayoutParams(mWidth, (int) (mWidth * heightRatio)));
viewPager.setPadding(mWidthPading, mHeightPading, mWidthPading, mHeightPading);
viewPager.setClipToPadding(false);
viewPager.setAdapter(cardAdapter);
showTransformer
改方法是设置ViewPager移动的时候,cardView放大效果和Elevation阴影效果,具体过程可以自行在ShadowTransformer
查看,实现过程上文基本也有覆盖。
总结
零零碎碎也捣鼓了一阵子的自定义View,我在想既然迈出这一步了,就得做好它。
人生总是要有信仰,有梦想才能一直前行,哪怕走的再慢,也是在前行。
如果这篇文章写的还凑合或者勾引起了你的斗志的话,欢迎点个star
github.com/qdxxxx/Bezi…
今天的文章敲酷炫的 ViewPager 切换效果和弹性指示器。分享到此就结束了,感谢您的阅读。
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
如需转载请保留出处:https://bianchenghao.cn/17555.html