前言
前不久,在Stack Overflow
上用自己半瓢水的動畫知識,幫助提問者解決了一個問題 的同時,看到很多眼生的方法,比如composed
,于是順便學習了一波自定義 Modifier
。回過頭來總結時,發現解決此問題的過程,非常適合作為一個案例去由淺入深的掌握自定義Modifier
~
步入正題!
相信大家既然已經在學習Compose了,那想必也非常熟悉如何使用 Modifer 了,由于Compose 被Android 團推設計的非常容易上手,所以有不了解如何使用的朋友可以去看看 文檔 ,即可輕松掌握基礎的使用!
擁有一個與眾不同的Modifier
,其實就是實現一個特別功能的Modifier
,然后使用它去修飾我們的Composable
可組合函數,來實現我們的特殊需求。
下面,我們就通過代碼一步一步來實戰一個特別功能的 Modifier
, 相信如果跟著過一遍的話,基本上也就掌握了自定義 Modifier
的知識。
1.1 需求,給 Composeable 添加虛線邊框
既然是添加邊框,想當然直接用 Modifier.border
fun Modifier.border(width: Dp, brush: Brush, shape: Shape): Modifier
然而,自帶的border()
提供了邊框寬度,邊框色彩,邊框形狀,但并沒有一個設置 “虛線” 的參數給我們,沒辦法要么等待官方猴年馬月之后更新支持,要么自己動手,豐衣足食 DIY 一個來用,豈不美哉!
1.2 繪制虛線
邊框并不屬于Composable
內容部分提供的,所以我們要把它繪制出來,然后依附在Composable
內容的邊上。我們仿照Modifier.border
,使用Modifier.drawXxx
來實現一個。
關于Compose draw
可以看這里使用 Jetpack Compose 完成自定義繪制
在 View 中,需要繪制虛線時,我們會用到DashPathEffect
來實現各式各樣的虛線,同樣,在Compose
中 PathEffect.dashPathEffect
用法基本保持一致
@Composable
fun ShowCard() {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.width(160.dp)
.height(50.dp)
.padding(2.dp)
.drawBehind {
// 繪制圓角矩形,可以滿足圓角邊框需求
drawRoundRect(
color = Color.Black,
style = Stroke(
width = 1f,
pathEffect = PathEffect.dashPathEffect(
intervals = floatArrayOf(20f, 20f),
phase = 0f
)
)
)
}
) {
Text(text = "看看四周的框框")
}
}
intervals
和 phase
,分別用來控制虛線的間隔,以及偏移量。
到這里,如果只是為了某一個 Composable
添加虛線邊框的話,已經初步滿足目的。但是,我們還想要把這個效果獨立出來
1.3 抽取為自定義 Modifier
其實Android
提供的自帶modifer
,比如 size()
padding()
等等,都是通過拓展函數的方式來實現鏈式調用。
創建一個拓展函數,同時我們提高一下可配置性,將一些屬性作為方法參數,并提供圓角大小,豐富一下功能:
fun Modifier.dashBorder(、
color: Color = Color.Black,
width: Dp = 1.dp,
cornerRadiusDp: Dp = 0.dp,
dashLength:Dp,
) = drawBehind {
drawRoundRect(
color = color,
style = Stroke(
width = width.toPx(),
pathEffect = PathEffect.dashPathEffect(
// 簡單起見,讓空白和線段的長度相同
intervals = floatArrayOf(dashLength.toPx(),dashLength.toPx()),
phase = 0f
)
),
cornerRadius = CornerRadius(cornerRadiusDp.toPx())
)
}
// 使用
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.width(160.dp)
.height(50.dp)
.padding(2.dp)
.dashBorder(
width = 1.dp,
intervals = 5.dp,
cornerRadiusDp = 5.dp
)
) {
Text(text = "看看四周的框框")
}
在Compose
中我們通常使用Dp
作為屏幕顯示單位,所以我們暴露方法參數最好使用Dp
,在繪制時,使用dp.toPx()
即可, 另外,建議提供默認參數值,讓代碼更簡潔。 到此,我們的自定義Modifier
已經實現了
- 可設置寬度的邊框
- 可設置虛線的長度
- 可添加圓角 完美!
2.1 如何讓邊框動起來?
其實,如果是針對需求的話,我們的Modifier
已經實現,但是,為了更好的學習,我們更進一步——讓我們的虛線邊框轉動起來!
分析,如果想要邊框動轉起來,我們應該找到能夠引發邊框沿著我們的四個邊轉動的角色,有自定義view經驗的朋友估計已經知道了,那就是 dashPathEffect
的 phase
phase,虛線的偏移量,說白了就是虛線開端偏移起點的距離。
動畫原理:讓虛線偏移一個完整虛線長度(包括線段和空白),然后restart
,這樣在視覺上看,就是一個無限延伸的線啦!
文字有點抽象,我們結合示意圖來分析,我們用實線代表虛線中的線,用虛線代表虛線中的空白,當線段從A
偏移到B
時,我們讓動畫 Restart
,這樣又會從A'
偏移到B'
,如此不斷Restart
,實現無限轉動效果。
2.2 使用 ComposedModifier 給邊框添加動畫
在Compose
中,動畫給狀態的改變提供絲滑的過渡效果,不可避免的,在 Modifier 中如果需要使用狀態 api (remember),要用到 ComposedModifier 來為我們提供一個帶有狀態的 Modifier
fun Modifier.composed(
inspectorInfo: InspectorInfo.() -> Unit = NoInspectorInfo,
factory: @Composable Modifier.() -> Modifier
): Modifier = this.then(ComposedModifier(inspectorInfo, factory))
使用拓展函數composed
即可拿到一個ComposedModifier
fun Modifier.dashBorder(
color: Color = Color.Black,
width: Dp = 1.dp,
dashLength:Dp,
cornerRadiusDp: Dp = 0.dp,
) = composed {
// 不在drawScope 中,無法直接使用 dp.toPx()
val density = LocalDensity.current
val dashLengthPx = density.run { dashLength.toPx() }
// 聲明一個無限循環動畫
val infinite = rememberInfiniteTransition()
val anim by infinite.animateFloat(initialValue = 0f,
targetValue = dashLengthPx*2,//偏移一個完整長度
animationSpec = infiniteRepeatable(
animation = tween(1000, easing = LinearEasing),
repeatMode = RepeatMode.Restart // 動畫循環模式為 restart
) )
drawBehind {
drawRoundRect(
color = color,
style = Stroke(
width = width.toPx(),
pathEffect = PathEffect.dashPathEffect(
intervals = floatArrayOf(dashLength.toPx(),dashLength.toPx()),
phase = anim // 動畫應用
)
),
cornerRadius = CornerRadius(cornerRadiusDp.toPx())
)
}
}
看看效果,至此,我們成功的讓邊框動起來啦??:
加餐 ??
讓動畫更加完美,增加動畫的暫停與繼續
思考:有時候,我們并不希望動畫一直在那里播放,那么如何控制動畫的暫停與恢復呢?
其實這里的已經偏向于動畫啦,感興趣的可以繼續閱讀
根據我的學習,很不幸,compose
并沒有提供直接控制動畫暫停與繼續的api
, 但是我們可以開動一下思維,變通一下,實現暫停與繼續的等價效果。
- 首先,我們需要使用更底層的
api
--Animatable
,來設置每次動畫的起始值。 - 然后我們增加一個參數
animate
來控制動畫是否播放, 新增一個狀態lastAnimValue
來記錄上次動畫的結束值,并且動畫的目標值也需要額外加上lastAnimValue
,保證動畫每次偏移是一個完整長度。 - 再次開始動畫時,將
lastAnimValue
作為本次動畫的起始值。
fun Modifier.dashsBorder(
color: Color = Color.Black,
width: Dp = 1.dp,
dashLength:Dp,
cornerRadiusDp: Dp = 0.dp,
animate: Boolean = true
) = composed {
var lastAnimValue by remember { mutableStateOf(0f) }
val anim = remember(animate) { Animatable(lastAnimValue) }
val density = LocalDensity.current
val dashLengthPx = density.run { dashLength.toPx() }
LaunchedEffect(animate) {
if (animate) {
anim.animateTo(
(dashLengthPx * 2 + lastAnimValue),
animationSpec =
infiniteRepeatable(
animation = tween(1000, easing = LinearEasing),
repeatMode = RepeatMode.Restart,
)
) {
lastAnimValue = value // store the anim value
}
}
}
drawBehind {
drawRoundRect(
color = color,
style = Stroke(
width = width.toPx(),
pathEffect = PathEffect.dashPathEffect(
intervals = floatArrayOf(dashLength.toPx(),dashLength.toPx()),
phase = anim.value
)
),
cornerRadius = CornerRadius(cornerRadiusDp.toPx())
)
}
}
看看效果,完美??:
總結
自定義Modifier
,在Compose
中是一個很有用的知識點,不少前輩大佬們利用它來實現了許許多多實用的功能,比如骨架屏,比如自由滾動。希望大家能掌握它,然后創造分享出更多的便利代碼庫??,這樣,就可以更早下班啦??~
參考資料
android - How to have dashed border in Jetpack Compose? - Stack Overflow
android - How to pause/resume a jetpack compose infinite transition animation - Stack Overflow