Jetpack Compose - Flutter 动态UI?
初遇 Flutter
2018-06月左右入坑Flutter,于是拿出美团和抖音等好看的界面感受了一波Flutter UI和绘制等写了三天的Demo也感受到了Flutter强大,当时匆匆忙忙就写了相关Demo上传了Github。不知不觉Github很多Star很开心,接着决定进行录制基础教学视频,在B站也收到了很多感谢私信、技术交流,记得2018年素未谋面的大哥因为我的热心无缘无故送我2018款MacBook Pro在他的再三坚持下。带着这份感动和感激我也开始写了很多提供初学者一起学习的文章和依赖库。
Flutter UI带来的惊喜
Flutter带来的惊喜不仅一点点:有状态的热重载可以快速重新渲染界面、富有表现力、极其灵活的UI。自身渲染引擎,脱离了原生基本组件不在作为映射,相对于Uni和RN等框架在性能方面占据绝对优势。滚动,导航,图标和字体,可以在iOS和Android上提供完整的原生性能体验,且支持Web, Desktop, Embedded 等多端。18年接触了Flutter就感受了一下UI的可塑性和创造性,完全不输原生且更加灵活多变,当时写的页面当然有很多这里列举其三:自定义裁剪和绘制让应用的UI突破了天花板,而手势和动画的加入更让你的应用,赏心悦目、超凡脱俗。那我们的Compose是否可以呢?今天主要的内容是探究Compose在UI上所表现的能力。
如果你对Flutter绘制和裁剪或者其他任何疑难杂症我想这个男人-张风捷特烈一定能给你说的清清楚楚
,透透彻彻
。
Compose UI 强大而简单
1、旋转、缩放、背景模糊
组件的旋转缩放如果用Flutter来实现如何做呢?Transform
或者RotationTransition
这样的容器部件进行包裹,且需要设置动画控制AnimationController等。那Compose我们如何来旋转和缩放组件呢?Modifier
不仅解决了参数过多可以统一配置问题,而且提供了极其强大的裁剪、变换、指针手势、装饰等方法,大大的提高了便捷性和创造性。
Modifier.rotate()
任意的组件都可以通过Modifier进行Modifier.rotate()
、Modifier.offset()
、Modifier.scale()
等变换。
观察这部分动态特效
1、中间图片的旋转。
2、圆形图片外圆弧缩放。
3、背景的模糊和缩放。
4、运动的曲线。
动画创建和使用
当我们鼠标点击界面时候中间的图片进行旋转了任意角度。我们已经知道旋转通过Modifier.rotate()
来设置,只需要在点击屏幕时候执行动画继续性更新角度值即可。所以简单动画创建需要掌握一下。
我们可以点击源码进行观看,动画执行过程中的值储存在mutableStateOf所以具有状态且发生改变时状态向下可以刷新UI,可以查看之前章节或者官网
//进行动画过程数值储存器
val animatedDegree = remember { Animatable(0f) }
通过Animatable.animateTo()
开始执行动画,可以设置动画目标值和动画规格速度等...
animatedOffset.animateTo(targetValue,animationSpec = spring(stiffness = StiffnessLow))
fun Modifier.pointerInput()
:以处理修改后的元素区域内的指针输入。pointerInput可以调用PointerInputScopeawaitPointerEventScope
来安装一个指针输入处理程序,该处理程序可以使waitPointerEventScope-awaitPointerEvent接收
和使用指针输入事件
。可以通过PointerInputScope.AwaitPointerEventScope
上的扩展功能定义为执行更高级别的手势检测。我们通过awaitPointerEventScope获取屏幕点击坐标作为我们的角度。
@Composable
fun LoginPage(){
//1.进行动画过程数值储存器
val animatedDegree = remember { Animatable(0f) }
Box() {
//底部模糊大背景
Image(
//获取模糊bitmap
bitmap = BitmapBlur.doBlur(getBitmap(resource =R.drawable.head_god).asAndroidBitmap(),
animatedRound.value.toInt()+20,false).asImageBitmap(),
contentDescription = "",
contentScale = ContentScale.FillHeight,
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
.scale(animatedScale.value, animatedScale.value),
)
//圆形图片和圆形弧圈
Column(verticalArrangement = Arrangement.Center, modifier = Modifier.pointerInput(Unit) {
coroutineScope {
while (true) {
//2.通过awaitPointerEventScope来处理指针事件
val offset = awaitPointerEventScope {
//获取第一次按下的位置坐标
awaitFirstDown().position
}
//携程
launch {
//3.设置animated的目标值为按下的屏幕坐标系内的x值,且设置动画格式为比较平缓不生硬。开始执行动画。
animatedDegree.animateTo(
offset.x,
animationSpec = spring(stiffness = StiffnessLow)
)
}
}
}
}) {
Image( bitmap = getBitmap(R.drawable.head_god),
contentDescription = "w",
contentScale = ContentScale.FillBounds,
modifier = Modifier
.height(80.dp)
.width(80.dp)
.background(color = Color(0XFF0DBEBF), shape = CircleShape)
.padding(3.dp)
.clip(
CircleShape
)
.shadow(elevation = 150.dp, clip = true)
.rotate(//4.设置角度为初始化到目标x的动画值更新UI
animatedDegreen.value
)
)
}
}
半透明圆环缩放和颜色动画
同样的通过val animatedScale = remember { Animatable(1f) }
来创建缩数值储存器,通过点击时候执行动画去跟新图片大小颜色也是同样设置val animatedColor = remember { Animatable(Color(206, 199, 250, 121)) }
点击时候去设置目标缩放值和颜色执行.animalTo即可执行连续动画且向上去跟新储存状态再下发到跟新UI。圆形裁剪相关的看之前文章
Box(contentAlignment = Alignment.Center,
modifier = Modifier.padding(0.dp).clip(CicleImageShape())
.background(animatedColor.value)
.width((130 * animatedScale.value).dp)
.height((130 * animatedScale.value).dp)
) {
Image(
bitmap = getBitmap(R.drawable.head_god),
contentDescription = "",
contentScale = ContentScale.FillBounds,
modifier = Modifier
.height(80.dp)
.width(80.dp)
.background(color = Color(0XFF0DBEBF), shape = CircleShape)
.padding(3.dp)
.clip(
CircleShape
)
.shadow(elevation = 150.dp, clip = true)
.rotate(
animatedOffset.value
)
)
}
图片高斯模糊的获取
Bitmap和ImageBitmap可以相互转换:Bitmap.asImageBitmap()
和ImageBitmap.asAndroidBitmap()
object BitmapBlur {
fun doBlur(sentBitmap: Bitmap, radiu: Int = 1, canReuseInBitmap: Boolean): Bitmap {
var radius: Int = radiu
val bitmap: Bitmap = if (canReuseInBitmap) {
sentBitmap
} else {
sentBitmap.copy(sentBitmap.config, true)
}
if (radius < 1) {
radius = 0
}
val w = bitmap.width
val h = bitmap.height
val pix = IntArray(w * h)
bitmap.getPixels(pix, 0, w, 0, 0, w, h)
val wm = w - 1
val hm = h - 1
val wh = w * h
val div = radius + radius + 1
val r = IntArray(wh)
val g = IntArray(wh)
val b = IntArray(wh)
var rsum: Int
var gsum: Int
var bsum: Int
var x: Int
var y: Int
var i: Int
var p: Int
var yp: Int
var yi: Int
var yw: Int
val vmin = IntArray(Math.max(w, h))
var divsum = div + 1 shr 1
divsum *= divsum
val dv = IntArray(256 * divsum)
i = 0
while (i < 256 * divsum) {
dv[i] = i / divsum
i++
}
yi = 0
yw = yi
val stack = Array(div) {
IntArray(
3
)
}
var stackpointer: Int
var stackstart: Int
var sir: IntArray
var rbs: Int
val r1 = radius + 1
var routsum: Int
var goutsum: Int
var boutsum: Int
var rinsum: Int
var ginsum: Int
var binsum: Int
y = 0
while (y < h) {
bsum = 0
gsum = bsum
rsum = gsum
boutsum = rsum
goutsum = boutsum
routsum = goutsum
binsum = routsum
ginsum = binsum
rinsum = ginsum
i = -radius
while (i <= radius) {
p = pix[yi + Math.min(wm, Math.max(i, 0))]
sir = stack[i + radius]
sir[0] = p and 0xff0000 shr 16
sir[1] = p and 0x00ff00 shr 8
sir[2] = p and 0x0000ff
rbs = r1 - Math.abs(i)
rsum += sir[0] * rbs
gsum += sir[1] * rbs
bsum += sir[2] * rbs
if (i > 0) {
rinsum += sir[0]
ginsum += sir[1]
binsum += sir[2]
} else {
routsum += sir[0]
goutsum += sir[1]
boutsum += sir[2]
}
i++
}
stackpointer = radius
x = 0
while (x < w) {
r[yi] = dv[rsum]
g[yi] = dv[gsum]
b[yi] = dv[bsum]
rsum -= routsum
gsum -= goutsum
bsum -= boutsum
stackstart = stackpointer - radius + div
sir = stack[stackstart % div]
routsum -= sir[0]
goutsum -= sir[1]
boutsum -= sir[2]
if (y == 0) {
vmin[x] = Math.min(x + radius + 1, wm)
}
p = pix[yw + vmin[x]]
sir[0] = p and 0xff0000 shr 16
sir[1] = p and 0x00ff00 shr 8
sir[2] = p and 0x0000ff
rinsum += sir[0]
ginsum += sir[1]
binsum += sir[2]
rsum += rinsum
gsum += ginsum
bsum += binsum
stackpointer = (stackpointer + 1) % div
sir = stack[stackpointer % div]
routsum += sir[0]
goutsum += sir[1]
boutsum += sir[2]
rinsum -= sir[0]
ginsum -= sir[1]
binsum -= sir[2]
yi++
x++
}
yw += w
y++
}
x = 0
while (x < w) {
bsum = 0
gsum = bsum
rsum = gsum
boutsum = rsum
goutsum = boutsum
routsum = goutsum
binsum = routsum
ginsum = binsum
rinsum = ginsum
yp = -radius * w
i = -radius
while (i <= radius) {
yi = Math.max(0, yp) + x
sir = stack[i + radius]
sir[0] = r[yi]
sir[1] = g[yi]
sir[2] = b[yi]
rbs = r1 - Math.abs(i)
rsum += r[yi] * rbs
gsum += g[yi] * rbs
bsum += b[yi] * rbs
if (i > 0) {
rinsum += sir[0]
ginsum += sir[1]
binsum += sir[2]
} else {
routsum += sir[0]
goutsum += sir[1]
boutsum += sir[2]
}
if (i < hm) {
yp += w
}
i++
}
yi = x
stackpointer = radius
y = 0
while (y < h) {
// Preserve alpha channel: ( 0xff000000 & pix[yi] )
pix[yi] =
-0x1000000 and pix[yi] or (dv[rsum] shl 16) or (dv[gsum] shl 8) or dv[bsum]
rsum -= routsum
gsum -= goutsum
bsum -= boutsum
stackstart = stackpointer - radius + div
sir = stack[stackstart % div]
routsum -= sir[0]
goutsum -= sir[1]
boutsum -= sir[2]
if (x == 0) {
vmin[y] = Math.min(y + r1, hm) * w
}
p = x + vmin[y]
sir[0] = r[p]
sir[1] = g[p]
sir[2] = b[p]
rinsum += sir[0]
ginsum += sir[1]
binsum += sir[2]
rsum += rinsum
gsum += ginsum
bsum += binsum
stackpointer = (stackpointer + 1) % div
sir = stack[stackpointer]
routsum += sir[0]
goutsum += sir[1]
boutsum += sir[2]
rinsum -= sir[0]
ginsum -= sir[1]
binsum -= sir[2]
yi += w
y++
}
x++
}
bitmap.setPixels(pix, 0, w, 0, 0, w, h)
return bitmap
}
}
贝塞尔曲线
凡是函数都可以和坐标系绘制进行一一映射,当然了贝塞尔曲线
也是有方程式的。有如下:
线性贝塞尔曲线
给定点P0、P1,线性贝塞尔曲线只是一条两点之间的直线。这条线由下式给出:
二次方贝塞尔曲线
二次方贝塞尔曲线的路径由给定点P0、P1、P2的函数B(t)追踪:
三次方贝塞尔曲线
P0、P1、P2、P3四个点在平面或在三维空间中定义了三次方贝塞尔曲线。曲线起始于P0走向P1,并从P2的方向来到P3。一般不会经过P1或P2;公式如下:
当然在Android端的Native层已经封装好了方法,二次方贝塞尔曲线
和三次方贝塞尔曲线
,已知函数当然可以进行封装。在Android端提供了二阶和三阶
二次方贝塞尔曲线:
public void quadTo(float x1, float y1, float x2, float y2)
public void rQuadTo(float dx1, float dy1, float dx2, float dy2)
三次方贝塞尔曲线:
public void cubicTo(float x1, float y1, float x2, float y2,float x3, float y3)
public void rCubicTo(float x1, float y1, float x2, float y2,float x3, float y3)
接下来我们绘制一个二阶曲线,控制点可以随着手势的移动和下按进行对应的屏幕移动,对于手势坐标系和屏幕坐标系的映射转换
上节折线里面说很明白了,这里不多做解释。
quadTo(float x1, float y1, float x2, float y2)
//记录移动的canvas画布坐标,不是手势坐标,由手势坐标转换为canvas坐标进行刷新
private var moveX: Float = 160f
private var moveY: Float = 160f
private fun drawQuz(canvas: Canvas) {
controllRect = Rect(
(moveX - 30f).toInt(),
(moveY + 30f).toInt(),
(moveX + 30).toInt(),
(moveY - 30f).toInt()
)
val quePath = Path()
canvas.drawCircle(0f, 0f, 10f, getPaintCir(Paint.Style.FILL))
canvas.drawCircle(320f, 0f, 10f, getPaintCir(Paint.Style.FILL))
//第一个点和控制点的连线到最后一个点链线。为了方便观察
val lineLeft = Path()
lineLeft.moveTo(0f, 0f)
lineLeft.lineTo(moveX, moveY)
lineLeft.lineTo(320f, 0f)
canvas.drawPath(lineLeft, getPaint(Paint.Style.STROKE))
//第一个p0处画一个圆。第二个p1处画一个控制点圆,最后画一个。
canvas.drawCircle(moveX, moveY, 10f, getPaintCir(Paint.Style.FILL))
quePath.quadTo(moveX, moveY, 320f, 0f)
canvas.drawPath(quePath, getPaint(Paint.Style.STROKE))
}
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
ACTION_DOWN,
ACTION_MOVE -> {
//在控制点附近范围内部,进行移动
Log.e("x=", "onTouchEvent: (x,y)"+(event.x - width / 2).toInt()+":"+(-(event.y - height / 2)).toInt())
//将手势坐标转换为屏幕坐标
moveX = event.x - width / 2
moveY = -(event.y - height / 2)
invalidate()
}
}
return true
}
上图可以拖动控制点,在起点和结尾之间的曲线随着控制点发生了变形。控制点靠近那一侧弧度的凸起就偏向那一侧
,初步的认识这一个规律即可,而练习中不断的去调节控制点达到我们的需求。但是在上图中我们会发现弧度不够圆圈,在三阶函数里面可以很好的调节弧度。接下来我们来看看三阶函数
三阶曲线
public void cubicTo(float x1, float y1, float x2, float y2,float x3, float y3)
同样我们在坐标系内绘制三阶曲线。为了很好的看到效果我们这次进行来精细的控制,我们可以拖动任意
我们想要拖动的控制点
进行观察我们的三阶曲线。在上章节折线中对于手势配合Rect的contains方法可以进行局部的点击
,当然了拖动也是没问题的。如下图:我们只需要在控制点附近进行绘制距形包裹住控制点
,手势滑动时时刷新控制点
和对应的距形
即可。
private fun drawCubic(canvas: Canvas) {
val cubicPath=Path()
cubicPath.moveTo(0f,0f)
cubicLeftRect= Rect(
(moveCubiX - 30f).toInt(),
(moveCubiY - 30f).toInt(),
(moveCubiX + 30).toInt(),
(moveCubiY + 30f).toInt()
)
cubicRightRect=Rect(
(moveCubiXX - 30f).toInt(),
(moveCubiYY - 30f).toInt(),
(moveCubiXX + 30).toInt(),
(moveCubiYY + 30f).toInt()
)
val lineLeft = Path()
lineLeft.moveTo(0f, 0f)
lineLeft.lineTo(moveCubiX, moveCubiY)
lineLeft.lineTo(moveCubiXX, moveCubiYY)
lineLeft.lineTo(320f, 0f)
canvas.drawPath(lineLeft, getPaint(Paint.Style.STROKE,Color.GRAY))
//canvas.drawRect(cubicLeftRect, getPaint(Paint.Style.FILL,Color.RED))
//canvas.drawRect(cubicRightRect, getPaint(Paint.Style.FILL,Color.RED))
canvas.drawCircle(moveCubiX, moveCubiY, 10f, getPaintCir(Paint.Style.FILL))
canvas.drawCircle(moveCubiXX, moveCubiYY, 10f, getPaintCir(Paint.Style.FILL))
cubicPath.cubicTo(moveCubiX,moveCubiY,moveCubiXX,moveCubiYY,320f,0f)
canvas.drawPath(cubicPath, getPaint(Paint.Style.STROKE,Color.RED))
}
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
ACTION_DOWN,
ACTION_MOVE -> {
//在控制点附近范围内部,进行移动
Log.e(
"x=",
"onTouchEvent: (x,y)" + (event.x - width / 2).toInt() + ":" + (-(event.y - height / 2)).toInt()
)
//二阶曲线
if (controllRect.contains((event.x - width / 2).toInt(),(-(event.y - height / 2)).toInt())) {
Log.e("点击来","对" )
moveX = event.x - width / 2
moveY = -(event.y - height / 2)
invalidate()
//三阶曲线控制点1
}else if(cubicLeftRect.contains((event.x - width / 2).toInt(),(-(event.y - height / 2)).toInt())){
moveCubiX= event.x - width / 2
moveCubiY= -(event.y - height / 2)
invalidate()
//三阶曲线控制点2
}else if(cubicRightRect.contains((event.x - width / 2).toInt(),(-(event.y - height / 2)).toInt())){
moveCubiXX= event.x - width / 2
moveCubiYY= -(event.y - height / 2)
invalidate()
}
}
}
return true
}
到这里我想我们应该大概的明白二阶和三阶曲线对于弧度的大致方向控制
了吧。你以为这样就结束了么。接下来下来开始正式的进入曲线动画。
如上图图片裁剪的弧度可以通过二阶曲线
进行绘制路径进行裁剪。中间点为控制点
即可,点击屏幕时候通过点击事件获取的屏幕x轴的值作为控制点的目标数值去执行动画即可。
Image(bitmap = BitmapBlur.doBlur(getBitmap(resource =R.drawable.head_god).asAndroidBitmap(),
animatedBitmap.value.toInt(),false).asImageBitmap(),
contentDescription = "",
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxWidth()
.height(230.dp)
.clip(//二阶曲线进行裁剪。
QureytoImageShapes(160f, animatedOffsetX.value)
)
.scale(animatedScale.value, animatedScale.value)//头部背景图片缩放
)
@Stable
class QureytoImageShapes(var hudu: Float = 100f, var controller:Float=0f) : Shape {
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density
): Outline {
val path = Path()
path.moveTo(0f, 0f)
path.lineTo(0f, size.height - hudu)
//默认初始化选择中间作为控制点坐标x的数值
if(controller==0f){
controller =size.width / 2f
}
path.quadraticBezierTo(controller, size.height, size.width, size.height - hudu)
path.lineTo(size.width, 0f)
path.close()
return Outline.Generic(path)
}
}
2、文字上色和动效
我们将渐变进行移动变换,那随着移动给文字着色就成了形成了这种效果 我们前几个章节已经知道,路径、画布可以进行平移变换,其实我们的
LinearGradient
也可以设置变换例如移动等通过linearGradient.setLocalMatrix(transMatrix)
文字随着路径动效
val animatedOffset = remember { Animatable(0f) }
....
点击时候触发animalTo设置目标值为屏幕坐标x即可。当然目标值自己可以定义其他的...看需求
Box(
contentAlignment = Alignment.Center, modifier = Modifier
.fillMaxWidth()
.padding(top = 20.dp)
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
androidx.compose.foundation.Canvas(
modifier = Modifier
.fillMaxWidth()
.draggable(state = DraggableState {
}, orientation = Orientation.Horizontal, onDragStarted = {
}, onDragStopped = {
}),
) {
drawIntoCanvas { canvas ->
val paint = Paint()
paint.style = PaintingStyle.Fill
paint.color = Color.Green
val text_paint = android.graphics.Paint()
text_paint.strokeWidth = 2f
text_paint.style = android.graphics.Paint.Style.FILL
text_paint.color = android.graphics.Color.BLACK
text_paint.textSize = 52f
//测量文字宽度
val rect = android.graphics.Rect()
text_paint.getTextBounds("ComposeUnit 登陆", 0, 6, rect)
val colors = intArrayOf(
android.graphics.Color.BLACK,
android.graphics.Color.argb(
250,
121,
animatedOffset.value.toInt(),
206
),
android.graphics.Color.argb(250, 121, 206, animatedOffset.value.toInt())
)
val positions = floatArrayOf(0.2f, 11f, 0.2f)
//让渐变动起来从而感觉到文字闪动起来了
val transMatrix = android.graphics.Matrix()
transMatrix.postTranslate(
-rect.width() + rect.width() * 2 * (animatedScale.value * 1.5f),
0f
)
//设置渐变
val linearGradient = android.graphics.LinearGradient(
0f,
0f,
rect.width().toFloat(),
0f,
colors,
positions,
android.graphics.Shader.TileMode.CLAMP
)
//设置矩阵变换
linearGradient.setLocalMatrix(transMatrix)
text_paint.shader = linearGradient
//1.坐标变换
canvas.nativeCanvas.drawText(
"ComposeUnit 登陆",
size.width / 3.5f,
size.height / 2.5f,
text_paint
)
val secontextPath = android.graphics.Path()
val rect1 = android.graphics.Rect()
text_paint.getTextBounds("更多精彩,更多体验 ~", 0, 6, rect1)
secontextPath.moveTo(340f, 100f)
//0-110
if (animatedOffset.value == 0f) {
secontextPath.quadTo(350f, 10f, 710f, 100f)
}
//设置曲线路径的控制点通过点击的x轴坐标来控制文字跟随路径的动效
secontextPath.quadTo(animatedOffset.value, 10f, 710f, 100f)
text_paint.textSize = 32f
text_paint.letterSpacing = 0.3f
//canvas.nativeCanvas.drawPath(secontextPath,text_paint)
canvas.nativeCanvas.drawTextOnPath(
"更多精彩,更多体验 ~",
secontextPath,
0f,
0f,
text_paint
)
}
}
}
}
3、没见过的输入框?
Modifier.border
加自定义绘制
即可完成。
Box(
contentAlignment = Alignment.Center, modifier = Modifier
.fillMaxWidth()
.padding(top = 60.dp)
) {
TextField(
value = "",
onValueChange = { },
// shape = AnimalRoundedCornerShape(animatedRound.value),
colors = TextFieldDefaults.textFieldColors(
unfocusedIndicatorColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
backgroundColor = Color.Transparent),
modifier = Modifier.height(48.dp).border(
1.2.dp,
//animatedColor.value.copy(alpha = 1f)
Color(animatedColor.value.red,animatedColor.value.green,animatedColor.value.blue,1f),
AnimalRoundedCornerShape(animatedRound.value)
),
leadingIcon = {
Icon(
bitmap = getBitmap(R.mipmap.yinzhang),
contentDescription = ""
)
})
}
//输入框边界border动画
@Stable
class AnimalRoundedCornerShape(val value:Float=30f):Shape{
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density
): Outline {
val path = Path()
path.lineTo(value,0f)
path.cubicTo(value,0f,0f,0f,0f,value)
path.lineTo(0f,size.height-value)
path.cubicTo(0f,size.height-value,0f,size.height,value,size.height)
path.quadraticBezierTo(size.width/2,size.height-value,size.width-value,size.height)
path.quadraticBezierTo(size.width,size.height,size.width,size.height-value)
path.lineTo(size.width,value)
path.quadraticBezierTo(size.width,0f,size.width-value,0f)
path.quadraticBezierTo(size.width/2,value,value,0f)
path.lineTo(value,0f)
return Outline.Generic(path)
}
}
4、动效CheckBox
CheckBox的动画同样 Modifier.clip()
即可。只不过这里注意的是裁剪时候需要自定义裁剪进行内部缩近裁剪,裁剪使用API提供CircleShaper设置半径不会起作用.
Checkbox(checked = true,
onCheckedChange = { },
colors = CheckboxDefaults.colors(checkedColor = Color(0XFF0DBEBF)),
modifier = Modifier.clip(CicleImageShape(animatedCheckBox.value))
//裁剪圆
@Stable
class CicleImageShape(val circle: Float = 0f) : Shape {
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density
): Outline {
val minWidth = Math.min(size.width-circle, size.width-circle)
val rect = Rect(circle, circle, minWidth, minWidth)
val path = Path()
path.addOval(rect)
return Outline.Generic(path)
}
}
BottomBar
BottomBar
底部导航栏在移动端必不可少,历史的长河中也没有太多大胆的设计?记得Flutter写过底部导航栏,当时Flutter群友们说很难自己写很费劲,我偷偷试了一下也就十几分钟对不对。那今天的主题是Compose那么是否Compose能够搞定这个?当然没问题,所有的UI无非曲线配合动画而已。接下来我们分析解决。1、基本的曲线绘制
Compose的bottomBar
源码看到提供一个@Compose,自定义任意发挥。
我们在完成的图看到有三个切换按钮,那我们如何绘制按钮和曲线呢?
Box(
modifier = Modifier
.fillMaxWidth(),
contentAlignment = Alignment.BottomEnd,
) {
Canvas(modifier = Modifier
.fillMaxWidth()
.height(70.dp), onDraw = {
drawIntoCanvas { canvas ->
val paint = Paint()
paint.color = Color(0XFF0DBEBF)
paint.style = PaintingStyle.Fill
val path = Path()
//先固定分为三等分
val widthOfOne = size.width / 3
//每一个弧度的中心控制点
val centerWidthOfOneX = widthOfOne / 2
//弧度端口到两遍ONewidth距离
val marginLeftAndRigth = centerWidthOfOneX / 1.6f
val controllerX = centerWidthOfOneX / 6f
//这个就是移动的过程从动画部分默认的第一个选中有弧度。
val keyAnimal = widthOfOne * 0
canvas.save()
//绘制圆圈背景
//canvas.drawCircle(Offset(centerWidthOfOneX + keyAnimal, 0f), 60f, paint)
//上文曲线懂了这里就是简单的调整,几分钟差不多
path.moveTo(0f, 0f)
path.lineTo(marginLeftAndRigth / 2 + keyAnimal, 0f)
path.cubicTo(
marginLeftAndRigth + keyAnimal,
0f,
centerWidthOfOneX - (centerWidthOfOneX - controllerX) / 2f + keyAnimal,
size.height / 3f,
centerWidthOfOneX + keyAnimal,
size.height / 2.6f
)
path.cubicTo(
centerWidthOfOneX + (centerWidthOfOneX - controllerX) / 2f + keyAnimal,
size.height / 2.6f,
widthOfOne - (marginLeftAndRigth) + keyAnimal,
0f,
widthOfOne - marginLeftAndRigth / 2 + keyAnimal,
0f
)
path.lineTo(size.width, 0f)
path.lineTo(size.width, size.height)
path.lineTo(0f, size.height)
path.close()
canvas.clipPath(path)
canvas.nativeCanvas.drawColor(Color(0XFF0DBEBF).toArgb())
}
})
Row(
modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceAround
) {
Image(
bitmap = getBitmap(resource = R.drawable.home),
contentDescription = "1",
modifier = Modifier
.modifier(animalCenterIndex, 0, animalBooleanState)
.clickable {
animalBoolean.value = !animalBoolean.value
animalCenterIndex.value = 0
}
)
Image(
bitmap = getBitmap(resource = R.drawable.center),
contentDescription = "1",
modifier = Modifier
.modifier(animalCenterIndex, 1, animalBooleanState)
.clickable {
animalBoolean.value = !animalBoolean.value
animalCenterIndex.value = 1
}
)
Image(
bitmap = getBitmap(resource = R.drawable.min),
contentDescription = "1",
modifier = Modifier
.modifier(animalCenterIndex, 2, animalBooleanState)
.clickable {
animalBoolean.value = !animalBoolean.value
animalCenterIndex.value = 2
}
)
}
}
背景圆圈如何添加当然简单的画布直接绘制圆圈最简单。
//绘制圆圈背景
canvas.drawCircle(Offset(centerWidthOfOneX + keyAnimal, 0f), 60f, paint)
2、曲线动画
点击时候弧度如何跟随变化呢?简单的整体坐标X平移size.width/3对应的倍数即可,例如第一个到第二个x轴坐标+size.width/31,第一个到第三个x轴坐标+size.width/32即可,其他同理。重点在如何获取点击选中的是哪一个index?Modifer.click点击我们可以设置选择的索引。加一个动画即可进行切换过渡效果。由于篇幅和时间问题动画单独开篇代码中案例有注可以自行观看。
@Composable
fun BottomNavigation(){
//记录点击选择的索引
val animalCenterIndex = remember { mutableStateOf(0) }
val animalBoolean = remember { mutableStateOf(true) }
val animalBooleanState: Float by animateFloatAsState(
if (animalBoolean.value) {
0f
} else {
1f
}, animationSpec = TweenSpec(durationMillis = 600)
)
//点击选择的状态变化,下发到animateFloatAsState里面动画执行开始
val indexValue: Float by animateFloatAsState(
//动画的目标值。当animalCenterIndex.value触发向下时候动画执行开始
when (animalCenterIndex.value) {
0 -> {
0f
}
1 -> {
1f
}
else -> {
2f
}
},
//设置动画的格式
animationSpec = TweenSpec(durationMillis = 500)
)
Box(
modifier = Modifier
.fillMaxWidth(),
contentAlignment = Alignment.BottomEnd,
) {
Canvas(modifier = Modifier
.fillMaxWidth()
.height(70.dp), onDraw = {
drawIntoCanvas { canvas ->
val paint = Paint()
paint.color = Color(0XFF0DBEBF)
paint.style = PaintingStyle.Fill
val path = Path()
//先固定分为三等分
val widthOfOne = size.width / 3
//每一个弧度的中心控制点
val centerWidthOfOneX = widthOfOne / 2
//弧度端口到两遍ONewidth距离
val marginLeftAndRigth = centerWidthOfOneX / 1.6f
val controllerX = centerWidthOfOneX / 6f
//⭐️⭐️⭐️最重要的更新所有的坐标点就看这里
val keyAnimal = widthOfOne * indexValue⭐️⭐️⭐️
canvas.save()
canvas.drawCircle(Offset(centerWidthOfOneX + keyAnimal, 0f), 60f, paint)
path.moveTo(0f, 0f)
path.lineTo(marginLeftAndRigth / 2 + keyAnimal, 0f)
path.cubicTo(
marginLeftAndRigth + keyAnimal,
0f,
centerWidthOfOneX - (centerWidthOfOneX - controllerX) / 2f + keyAnimal,
size.height / 3f,
centerWidthOfOneX + keyAnimal,
size.height / 2.6f
)
path.cubicTo(
centerWidthOfOneX + (centerWidthOfOneX - controllerX) / 2f + keyAnimal,
size.height / 2.6f,
widthOfOne - (marginLeftAndRigth) + keyAnimal,
0f,
widthOfOne - marginLeftAndRigth / 2 + keyAnimal,
0f
)
path.lineTo(size.width, 0f)
path.lineTo(size.width, size.height)
path.lineTo(0f, size.height)
path.close()
canvas.clipPath(path)
canvas.nativeCanvas.drawColor(Color(0XFF0DBEBF).toArgb())
}
})
Row(
modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceAround
) {
Image(
bitmap = getBitmap(resource = R.drawable.home),
contentDescription = "1",
modifier = Modifier
.modifier(animalCenterIndex, 0, animalBooleanState)
.clickable {
animalBoolean.value = !animalBoolean.value
animalCenterIndex.value = 0
}
)
Image(
bitmap = getBitmap(resource = R.drawable.center),
contentDescription = "1",
modifier = Modifier
.modifier(animalCenterIndex, 1, animalBooleanState)
.clickable {
animalBoolean.value = !animalBoolean.value
animalCenterIndex.value = 1
}
)
Image(
bitmap = getBitmap(resource = R.drawable.min),
contentDescription = "1",
modifier = Modifier
.modifier(animalCenterIndex, 2, animalBooleanState)
.clickable {
animalBoolean.value = !animalBoolean.value
animalCenterIndex.value = 2
}
)
}
}
}
//将点击选择进行封装起来。单独处理返回按钮位置和是否旋转。
fun Modifier.modifier(
animalCenterIndex: MutableState<Int>,
i: Int,
animalBooleanState: Float
): Modifier {
return if (animalCenterIndex.value == i) {
return Modifier
.padding(bottom = 57.dp)
.width(25.dp)
.height(25.dp)
.rotate(animalBooleanState * 360)
} else {
return Modifier
.padding(top = 20.dp)
.width(25.dp)
.height(25.dp)
}
}
3、还有什么UI办不到?
写不好UI不是程序员的锅,只怪产品经理不够变态。当然了根据手机壳变颜色的我的确不行。百度Google了大半天,也没发现很难的界面,下面界面貌似还可以。
这是UI第三篇文章,我想这里我就不在重复曲线的内容了。
Box(contentAlignment = Alignment.TopStart) {
androidx.compose.foundation.Canvas(
modifier = Modifier
.fillMaxHeight()
.width(250.dp),
onDraw = {
drawIntoCanvas { canvas ->
val paint = Paint()
paint.color = Color(36, 36, 92, 255)
paint.style = PaintingStyle.Fill
paint.isAntiAlias = true
paint.blendMode = BlendMode.ColorDodge
val roundRect = Path()
roundRect.moveTo(0f, 0f)
roundRect.lineTo(size.width - 350f, 0f)
roundRect.quadraticBezierTo(
size.width,
size.height / 2f,
size.width - 350f,
size.height
)
roundRect.lineTo(0f, size.height)
roundRect.close()
canvas.clipPath(roundRect)
canvas.drawPath(roundRect, paint)
}
}
)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxHeight()
) {
Image(
bitmap = getBitmap(R.drawable.head_god),
contentDescription = "w",
contentScale = ContentScale.FillBounds,
modifier = Modifier
.height(50.dp)
.width(50.dp)
.offset(x = 40.dp, y = 50.dp)
.background(color = Color(0XFF0DBEBF), shape = CircleShape)
.padding(3.dp)
.clip(
CircleShape
)
.shadow(elevation = 150.dp, clip = true)
.rotate(
animatedOffset.value
)
)
Column(
modifier = Modifier.offset(x = 40.dp, y = 50.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = "Hello_World", fontSize = 13.sp, color = Color.White)
Text(text = "路很长一加油", fontSize = 8.sp, color = Color.White)
Row(modifier = Modifier.padding(top = 45.dp)) {
Image(
bitmap = getBitmap(resource = R.drawable.home),
contentDescription = "1",
modifier = Modifier
.clickable {
}.padding(end = 15.dp)
)
Text(text = "Login", fontSize = 13.sp, color = Color.White)
}
Row(modifier = Modifier.padding(top = 45.dp)) {
Image(
bitmap = getBitmap(resource = R.drawable.home),
contentDescription = "1",
modifier = Modifier
.clickable {
}.padding(end = 15.dp)
)
Text(text = "Login", fontSize = 13.sp, color = Color.White)
}
Row(modifier = Modifier.padding(top =45.dp)) {
Image(
bitmap = getBitmap(resource = R.drawable.home),
contentDescription = "1",
modifier = Modifier
.clickable {
}.padding(end = 15.dp)
)
Text(text = "Login", fontSize = 13.sp, color = Color.White)
}
Row(modifier = Modifier.padding(top = 45.dp)) {
Image(
bitmap = getBitmap(resource = R.drawable.home),
contentDescription = "1",
modifier = Modifier
.clickable {
}.padding(end = 15.dp)
)
Text(text = "Login", fontSize = 13.sp, color = Color.White)
}
Row(modifier = Modifier.padding(top = 95.dp)) {
Image(
bitmap = getBitmap(resource = R.drawable.home),
contentDescription = "1",
modifier = Modifier
.clickable {
}.padding(end = 15.dp)
)
Text(text = "Login", fontSize = 13.sp, color = Color.White)
}
Row(modifier = Modifier.padding(top = 45.dp)) {
Image(
bitmap = getBitmap(resource = R.drawable.home),
contentDescription = "1",
modifier = Modifier
.clickable {
}.padding(end = 15.dp)
)
Text(text = "Login", fontSize = 13.sp, color = Color.White)
}
Row(modifier = Modifier.padding(top = 45.dp)) {
Image(
bitmap = getBitmap(resource = R.drawable.home),
contentDescription = "1",
modifier = Modifier
.clickable {
}.padding(end = 15.dp)
)
Text(text = "Login", fontSize = 13.sp, color = Color.White)
}
}
}
}
效果如下
如果你觉得不够花哨,我建议搞个动画点击哪里曲线凸起来跑哪里,我就不多废话。
4、我觉得你能行
曲线在很多场景中发挥着不可替代的作用,如果你的UI和交互想要别具一格,自定义是一个很重要的技能。到这里我甚至想不出有什么样的二纬UI是我们搞不定的。如果有,请你加QQ群730772561
一起讨论研究。下面图留给你作业,感受一下自己的UI水平。
贴个我写的效果?
- FIN -
使用Jetpack Compose完成你的自定义Layout
Compose 博物馆网站:https://compose.net.cn/
添加微信进入 Compose 技术交流群