可实现的效果展示
翻页控件演示视频
1、具体控件代码
package com.example.test.ui.widget
import android.animation.ValueAnimator
import android.content.Context
import android.graphics.Camera
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Path
import android.graphics.Rect
import android.graphics.RectF
import android.util.AttributeSet
import android.util.Log
import android.view.animation.LinearInterpolator
import android.widget.FrameLayout
import androidx.core.animation.doOnEnd
import androidx.core.animation.doOnStart
import androidx.core.graphics.withClip
import androidx.core.graphics.withSave
class FlipCardView @JvmOverloads constructor(
context: Context,
attr: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attr, defStyleAttr) {
private val tag = "FlipCardView"
private var bgRadius = 50f //背景圆角
private var gapHeight = 15f //上下背景的间隔
private var centerX = 0f //中心点X坐标
private var centerY = 0f //中心点Y坐标
private var textYPos = 0f //文本Y坐标
private val camera = Camera() //画布的虚拟相机
private var degree = 0f //旋转角度
private var textList = listOf<String>()
private var showTextIndex = 0 //当前显示的文本索引,但注意由于实现方式 显示的文本是索引+1 的文本
private var textRect = Rect() //文本区域
// 上,下 区域 使用path而不是rectF是为了更好的切圆角
private var upRectPath = Path() // 上区域
private var downRectPath = Path() // 下区域
private var upTextRectPath = Path() // 上文本区域
private var downTextRectPath = Path() // 下文本区域
private var gapRect = RectF() //间隔区域
private var loop = true // 文本循环滚动开关
private var autoFlip = true //自动翻转开关
private var flipDoEnd : () -> Unit = {} // 列表翻转一个轮回的回调
//旋转动画
private var flipAnimator = ValueAnimator.ofInt(0, 180).apply {
duration = 500
interpolator = LinearInterpolator()
doOnStart {
if ((showTextIndex + 1) % textList.size == 0){
flipDoEnd.invoke()
}
}
addUpdateListener {
degree = it.animatedValue.toString().toFloat()
invalidate()
}
doOnEnd {
post {
if (autoFlip && (showTextIndex < textList.size - 1 || loop)) {
postDelayed({start()},500)
}
showTextIndex = (showTextIndex + 1) % textList.size
}
}
}
// 背景画笔
private var bgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.GRAY
}
// 旋转背景画笔
private var bgFlipPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.GRAY
}
// 间隔画笔
private var gapPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.BLACK
}
//文本画笔
private var textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textSize = 600f
color = Color.WHITE
textAlign = Paint.Align.CENTER
}
init {
setWillNotDraw(false)
}
fun startFlip() {
flipAnimator.start()
}
fun addFlipDoEnd( flipDoEnd : () -> Unit){
this.flipDoEnd = flipDoEnd
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
//绘制上下背景
canvas.drawPath(upRectPath, bgPaint)
canvas.drawPath(downRectPath, bgPaint)
//绘制文本 上背景上永远绘制 下一个数 下背景绘制当前数
canvas.withClip(upRectPath) {
drawText(textList[(showTextIndex + 1) % textList.size], centerX, textYPos, textPaint)
}
canvas.withClip(downRectPath) {
drawText(textList[showTextIndex], centerX, textYPos, textPaint)
}
// 绘制旋转背景
canvas.withSave {
camera.save()
canvas.translate(centerX, centerY)
camera.rotateX(if (degree < 90) -degree else 180 - degree)
camera.applyToCanvas(canvas)
canvas.translate(-centerX, -centerY)
camera.restore()
//角度小于90度时,绘制上背景展示当前值,否则绘制下背景展示下个数
if (degree < 90) {
canvas.drawPath(upTextRectPath, bgFlipPaint)
canvas.withClip(upTextRectPath) {
drawText(textList[showTextIndex], centerX, textYPos, textPaint)
}
} else {
canvas.drawPath(downTextRectPath, bgFlipPaint)
canvas.withClip(downTextRectPath) {
drawText(
textList[(showTextIndex + 1) % textList.size],
centerX,
textYPos,
textPaint
)
}
}
}
canvas.drawRect(gapRect, gapPaint)
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
centerY = h / 2f
centerX = w / 2f
val gapHeightHalf = gapHeight / 2f
// 获取高度最大的文本(同时得到文本和高度)
val (maxText, maxTextHeight) = textList.maxByOrNull { text ->
val rect = Rect()
textPaint.getTextBounds(text, 0, text.length, rect)
rect.height()
}?.let { maxText ->
val rect = Rect()
textPaint.getTextBounds(maxText, 0, maxText.length, rect)
maxText to rect.height().toFloat() // 返回 Pair<文本, 高度>
} ?: run {
"" to 0f // 如果 textList 为空,返回默认值
}
textPaint.getTextBounds(maxText, 0, maxText.length, textRect)
textYPos = centerY + textRect.height() / 2f - gapHeight / 2f
val upRect = RectF(0f, 0f, w.toFloat(), centerY - gapHeightHalf)
val downRect = RectF(0f, centerY + gapHeightHalf, w.toFloat(), h.toFloat())
val gapTextToRectHalf = (upRect.height() - maxTextHeight / 2f + gapHeightHalf) / 4f
val upTextRect =
RectF(0f, upRect.top + gapTextToRectHalf * 3, w.toFloat(), centerY - gapHeightHalf)
val downTextRect =
RectF(0f, centerY + gapHeightHalf, w.toFloat(), downRect.bottom - gapTextToRectHalf * 3)
//设置圆角
val upShape = floatArrayOf(
bgRadius, bgRadius, // 左上
bgRadius, bgRadius, // 右上
0f, 0f, // 右下
0f, 0f // 左下
)
val downShape = floatArrayOf(
0f, 0f, // 左上
0f, 0f, // 右上
bgRadius, bgRadius, // 右下
bgRadius, bgRadius, // 左下
)
upRectPath.addRoundRect(upRect, upShape, Path.Direction.CW)
upTextRectPath.addRoundRect(upTextRect, upShape, Path.Direction.CW)
downRectPath.addRoundRect(downRect, downShape, Path.Direction.CW)
downTextRectPath.addRoundRect(downTextRect, downShape, Path.Direction.CW)
gapRect.set(0f, upRect.bottom, w.toFloat(), downRect.top)
}
override fun onDetachedFromWindow() {
flipAnimator.cancel()
flipAnimator = null
super.onDetachedFromWindow()
}
private fun log(message: String) {
Log.d(tag, message)
}
data class Builder(
private val flipCardView : FlipCardView,
val context: Context,
var autoFlip: Boolean = true,
var loop : Boolean = true,
var startIndex : Int = 0,
var bgRadius: Int = 20,
var gapHeight: Int = 5,
var textSize: Int = 70,
var textColor: Int = Color.WHITE,
var bgColor : Int = Color.GRAY,
var gapColor : Int = Color.BLACK,
var textList: List<String> = listOf("1", "2"),
){
/**
* dp 转 px
* @param dpValue dp值
*/
private fun dp2px(context: Context, dpValue: Int): Float {
val scale = context.resources.displayMetrics.density
return dpValue * scale + 0.5f // +0.5f 是为了四舍五入
}
/**
* sp 转 px
* @param spValue sp值
*/
private fun sp2px(context: Context, spValue: Int): Float {
val scale = context.resources.displayMetrics.scaledDensity
return spValue * scale + 0.5f
}
fun build(){
flipCardView.autoFlip = autoFlip
flipCardView.loop = loop
flipCardView.bgRadius = dp2px(context,bgRadius)
flipCardView.gapHeight = dp2px(context,gapHeight)
flipCardView.textPaint.textSize = sp2px(context , textSize)
flipCardView.bgPaint.color = bgColor
flipCardView.gapPaint.color = gapColor
flipCardView.textList = textList
flipCardView.textPaint.textSize = sp2px(context, textSize).toFloat()
flipCardView.textPaint.color = textColor
flipCardView.bgFlipPaint.color = bgColor
flipCardView.gapPaint.color = gapColor
flipCardView.showTextIndex = if (startIndex in textList.indices) startIndex else 0
}
}
}
// 调用的扩展函数
fun FlipCardView.builder(): FlipCardView.Builder {
return FlipCardView.Builder(this, context)
}
2、使用
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="20dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@+id/titleLayout">
<com.example.lottery.ui.widget.FlipCardView
android:id="@+id/hourView"
android:layout_width="0dp"
android:layout_height="100dp"
android:layout_weight="1"/>
<com.example.lottery.ui.widget.FlipCardView
android:id="@+id/minuteView"
android:layout_width="0dp"
android:layout_height="100dp"
android:layout_weight="1"
android:layout_marginHorizontal="10dp" />
<com.example.lottery.ui.widget.FlipCardView
android:id="@+id/secondView"
android:layout_width="0dp"
android:layout_height="100dp"
android:layout_weight="1"/>
</LinearLayout>
下面的是实现时钟的代码,也可以自己组合实现倒计时等别的效果
val calendar = Calendar.getInstance()
val hour = calendar.get(Calendar.HOUR_OF_DAY) // 24小时制 (0-23)
val minute = calendar.get(Calendar.MINUTE) // 分钟 (0-59)
val second = calendar.get(Calendar.SECOND) // 秒 (0-59)
binding.hourView.builder().apply {
autoFlip = false
startIndex = hour
bgRadius = 10
textList = (0 until 24).map { "%02d".format(it) }
}.build()
binding.minuteView.builder().apply {
autoFlip = false
startIndex = minute
bgRadius = 10
textList = (0 until 60).map { "%02d".format(it) }
}.build()
binding.secondView.builder().apply {
autoFlip = true
startIndex = second
bgRadius = 10
textList = (0 until 60).map { "%02d".format(it) }
}.build()
binding.secondView.addFlipDoEnd {
binding.minuteView.startFlip()
}
binding.minuteView.addFlipDoEnd {
binding.hourView.startFlip()
}
binding.secondView.startFlip()