MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Kotlin Android自定义视图

2022-11-235.7k 阅读

Kotlin Android自定义视图基础

自定义视图的重要性

在Android开发中,自定义视图是一项非常强大且重要的技能。原生提供的视图虽然能够满足大部分常见需求,但在实际项目里,为了实现独特的用户界面和交互效果,往往需要创建自定义视图。通过自定义视图,可以将复杂的界面元素封装成独立的组件,提高代码的复用性和可维护性。例如,开发一个具有独特动画效果的进度条,或者设计一个可以自由拖拽缩放的图片展示视图,这些都离不开自定义视图技术。

Kotlin在自定义视图中的优势

Kotlin作为一种现代的编程语言,在Android自定义视图开发中具有诸多优势。它简洁的语法使得代码量大幅减少,同时提升了代码的可读性。Kotlin的空安全特性可以有效避免在视图操作过程中可能出现的空指针异常,这在处理视图属性和事件时尤为重要。另外,Kotlin与Java的高度兼容性,使得开发者可以轻松地将现有的Java自定义视图代码迁移到Kotlin,或者在Kotlin项目中混合使用Java代码。

自定义视图的分类

  1. 继承现有视图:这种方式最为简单,通过继承如TextViewImageView等原生视图类,然后根据需求重写其方法来实现自定义功能。例如,想要一个带有特定背景渐变效果的TextView,就可以继承TextView类,在onDraw方法中绘制渐变背景。
  2. 组合视图:将多个原生视图组合在一起形成一个新的视图。比如,创建一个包含图片和文本描述的自定义卡片视图,就可以通过在布局文件中组合ImageViewTextView,并在代码中进行统一的管理和交互逻辑编写。
  3. 完全自定义视图:从头开始创建一个全新的视图,需要自己处理测量、布局和绘制等各个环节。这种方式灵活性最高,但实现难度也较大,适用于实现非常独特的视图效果。

创建简单的自定义视图(继承现有视图)

继承TextView实现自定义样式

假设我们要创建一个自定义的TextView,它具有特殊的文本颜色和下划线效果。

  1. 创建Kotlin类
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.util.AttributeSet
import android.widget.TextView

class CustomTextView : TextView {
    private val paint: Paint = Paint()

    constructor(context: Context) : super(context) {
        init()
    }

    constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
        init()
    }

    constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
        init()
    }

    private fun init() {
        paint.color = resources.getColor(android.R.color.holo_blue_dark)
        paint.isUnderlineText = true
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        val textWidth = paint.measureText(text.toString())
        canvas.drawLine(0f, height - 2f, textWidth, height - 2f, paint)
    }
}
  1. 在布局中使用
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <com.example.kotlin.CustomTextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="自定义TextView示例" />
</LinearLayout>

在上述代码中,我们通过继承TextView类创建了CustomTextView。在构造函数中初始化画笔属性,在onDraw方法中绘制下划线。这样就实现了一个具有特殊样式的TextView

为自定义视图添加属性

  1. 定义属性:在res/values/attrs.xml文件中定义自定义属性。
<resources>
    <declare-styleable name="CustomTextView">
        <attr name="customTextColor" format="color" />
        <attr name="isUnderline" format="boolean" />
    </declare-styleable>
</resources>
  1. 在自定义视图中获取属性
class CustomTextView : TextView {
    private val paint: Paint = Paint()
    private var customTextColor: Int = 0
    private var isUnderline: Boolean = false

    constructor(context: Context) : super(context) {
        init(null, 0)
    }

    constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
        init(attrs, 0)
    }

    constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
        init(attrs, defStyleAttr)
    }

    private fun init(attrs: AttributeSet?, defStyle: Int) {
        val a = context.obtainStyledAttributes(
            attrs,
            R.styleable.CustomTextView,
            defStyle, 0
        )

        customTextColor = a.getColor(R.styleable.CustomTextView_customTextColor, resources.getColor(android.R.color.holo_blue_dark))
        isUnderline = a.getBoolean(R.styleable.CustomTextView_isUnderline, false)

        a.recycle()

        paint.color = customTextColor
        paint.isUnderlineText = isUnderline
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        if (isUnderline) {
            val textWidth = paint.measureText(text.toString())
            canvas.drawLine(0f, height - 2f, textWidth, height - 2f, paint)
        }
    }
}
  1. 在布局中使用属性
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <com.example.kotlin.CustomTextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="自定义TextView示例"
        app:customTextColor="#FF0000"
        app:isUnderline="true" />
</LinearLayout>

通过这种方式,我们可以在布局文件中灵活地设置自定义视图的属性,增加了视图的可配置性。

组合视图

创建组合视图示例

假设我们要创建一个包含图片和文本的自定义卡片视图。

  1. 创建布局文件:在res/layout/custom_card_view.xml中定义组合视图的布局。
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="horizontal"
    android:padding="16dp">

    <ImageView
        android:id="@+id/card_image"
        android:layout_width="64dp"
        android:layout_height="64dp"
        android:src="@drawable/ic_launcher_background" />

    <TextView
        android:id="@+id/card_text"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="16dp"
        android:text="卡片文本" />
</LinearLayout>
  1. 创建Kotlin类
import android.content.Context
import android.util.AttributeSet
import android.widget.LinearLayout
import android.view.LayoutInflater
import kotlinx.android.synthetic.main.custom_card_view.view.*

class CustomCardView : LinearLayout {
    constructor(context: Context) : super(context) {
        init()
    }

    constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
        init()
    }

    constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
        init()
    }

    private fun init() {
        LayoutInflater.from(context).inflate(R.layout.custom_card_view, this, true)
    }

    fun setCardText(text: String) {
        card_text.text = text
    }

    fun setCardImageResource(resourceId: Int) {
        card_image.setImageResource(resourceId)
    }
}
  1. 在布局中使用
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <com.example.kotlin.CustomCardView
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />
</LinearLayout>

在代码中,我们通过LayoutInflater将自定义的布局文件加载到LinearLayout中,然后提供方法来设置图片和文本内容,方便在其他地方使用该组合视图。

处理组合视图的事件

为了使组合视图更加交互性,我们可以为其添加点击事件等。

  1. 在Kotlin类中添加点击事件处理
class CustomCardView : LinearLayout {
    // 构造函数不变

    private fun init() {
        LayoutInflater.from(context).inflate(R.layout.custom_card_view, this, true)
        setOnClickListener {
            // 处理点击事件逻辑
            Toast.makeText(context, "卡片被点击了", Toast.LENGTH_SHORT).show()
        }
    }

    // 设置文本和图片方法不变
}

这样,当用户点击自定义卡片视图时,就会弹出一个提示消息。

完全自定义视图

自定义视图的测量

在完全自定义视图中,测量是一个重要的环节,它决定了视图的大小。

  1. 重写onMeasure方法
import android.content.Context
import android.graphics.Canvas
import android.util.AttributeSet
import android.view.View

class CustomView : View {
    constructor(context: Context) : super(context)

    constructor(context: Context, attrs: AttributeSet) : super(context, attrs)

    constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr)

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        var widthMode = MeasureSpec.getMode(widthMeasureSpec)
        var widthSize = MeasureSpec.getSize(widthMeasureSpec)
        var heightMode = MeasureSpec.getMode(heightMeasureSpec)
        var heightSize = MeasureSpec.getSize(heightMeasureSpec)

        var measuredWidth: Int
        var measuredHeight: Int

        if (widthMode == MeasureSpec.EXACTLY) {
            measuredWidth = widthSize
        } else {
            // 这里可以根据视图内容计算宽度
            measuredWidth = 200
        }

        if (heightMode == MeasureSpec.EXACTLY) {
            measuredHeight = heightSize
        } else {
            // 这里可以根据视图内容计算高度
            measuredHeight = 200
        }

        setMeasuredDimension(measuredWidth, measuredHeight)
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        // 绘制逻辑
    }
}

onMeasure方法中,通过MeasureSpec类获取测量模式和尺寸。MeasureSpec.EXACTLY表示父视图已经指定了确切的尺寸,MeasureSpec.AT_MOST表示视图可以在不超过父视图给定尺寸的情况下自由调整大小,MeasureSpec.UNSPECIFIED表示父视图没有限制视图的大小。根据不同的测量模式,我们可以计算出合适的视图尺寸,并通过setMeasuredDimension方法设置。

自定义视图的布局

布局阶段决定了视图在父容器中的位置。

  1. 重写onLayout方法
class CustomViewGroup : ViewGroup {
    constructor(context: Context) : super(context)

    constructor(context: Context, attrs: AttributeSet) : super(context, attrs)

    constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr)

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        // 测量逻辑,这里简单处理
        var widthMode = MeasureSpec.getMode(widthMeasureSpec)
        var widthSize = MeasureSpec.getSize(widthMeasureSpec)
        var heightMode = MeasureSpec.getMode(heightMeasureSpec)
        var heightSize = MeasureSpec.getSize(heightMeasureSpec)

        setMeasuredDimension(widthSize, heightSize)
    }

    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        for (i in 0 until childCount) {
            val child = getChildAt(i)
            child.layout(
                l + i * 100,
                t,
                l + (i + 1) * 100,
                t + child.measuredHeight
            )
        }
    }
}

onLayout方法中,我们遍历所有子视图,并根据一定的规则为它们指定位置。这里简单地将子视图水平排列,每个子视图之间间隔100像素。

自定义视图的绘制

绘制是自定义视图最核心的部分,通过CanvasPaint来绘制图形。

  1. onDraw方法中绘制图形
class CustomView : View {
    private val paint: Paint = Paint()

    constructor(context: Context) : super(context) {
        init()
    }

    constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
        init()
    }

    constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
        init()
    }

    private fun init() {
        paint.color = Color.RED
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        canvas.drawCircle(width / 2f, height / 2f, 100f, paint)
    }
}

在上述代码中,我们在onDraw方法中使用Canvas绘制了一个红色的圆形,圆心位于视图的中心,半径为100像素。

自定义视图的动画

视图动画

  1. 位移动画
val anim = TranslateAnimation(0f, 200f, 0f, 0f)
anim.duration = 1000
customView.startAnimation(anim)

上述代码创建了一个位移动画,将customView从初始位置水平移动200像素,动画时长为1秒。 2. 透明度动画

val anim = AlphaAnimation(1f, 0.5f)
anim.duration = 1000
customView.startAnimation(anim)

此代码创建了一个透明度动画,将customView的透明度从1(不透明)变为0.5(半透明),动画时长为1秒。

属性动画

  1. 属性动画改变视图位置
ObjectAnimator.ofFloat(customView, "translationX", 0f, 200f).apply {
    duration = 1000
    start()
}

通过ObjectAnimator,我们可以直接对视图的属性进行动画操作。这里将customViewtranslationX属性从0变为200,实现水平移动动画,时长为1秒。 2. 属性动画组合

val animatorSet = AnimatorSet()
val anim1 = ObjectAnimator.ofFloat(customView, "translationX", 0f, 200f)
val anim2 = ObjectAnimator.ofFloat(customView, "alpha", 1f, 0.5f)
animatorSet.playTogether(anim1, anim2)
animatorSet.duration = 1000
animatorSet.start()

这段代码创建了一个动画集合,同时执行位移动画和透明度动画,使得视图在移动的同时透明度发生变化。

自定义视图与触摸事件

处理触摸事件

  1. 重写onTouchEvent方法
class CustomView : View {
    private var startX: Float = 0f
    private var startY: Float = 0f

    constructor(context: Context) : super(context)

    constructor(context: Context, attrs: AttributeSet) : super(context, attrs)

    constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr)

    override fun onTouchEvent(event: MotionEvent): Boolean {
        when (event.action) {
            MotionEvent.ACTION_DOWN -> {
                startX = event.x
                startY = event.y
                return true
            }
            MotionEvent.ACTION_MOVE -> {
                val dx = event.x - startX
                val dy = event.y - startY
                // 根据dx和dy处理视图移动等逻辑
                return true
            }
            MotionEvent.ACTION_UP -> {
                // 处理抬起事件逻辑
                return true
            }
        }
        return super.onTouchEvent(event)
    }
}

onTouchEvent方法中,通过MotionEventaction来判断触摸事件的类型,如按下、移动和抬起。在不同的事件类型中,可以获取触摸点的坐标,并根据需求处理视图的交互逻辑,比如实现视图的拖拽效果。

事件分发机制

  1. 理解事件分发:在Android中,触摸事件从父视图开始分发,依次经过dispatchTouchEventonInterceptTouchEvent(仅ViewGroup有)和onTouchEvent方法。dispatchTouchEvent决定是否分发事件,onInterceptTouchEvent决定是否拦截事件,onTouchEvent处理事件。
  2. 自定义事件分发示例
class CustomViewGroup : ViewGroup {
    constructor(context: Context) : super(context)

    constructor(context: Context, attrs: AttributeSet) : super(context, attrs)

    constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr)

    override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
        // 这里可以决定是否分发事件给子视图
        return super.dispatchTouchEvent(ev)
    }

    override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
        // 这里可以决定是否拦截事件
        return super.onInterceptTouchEvent(ev)
    }

    override fun onTouchEvent(ev: MotionEvent): Boolean {
        // 处理触摸事件逻辑
        return super.onTouchEvent(ev)
    }
}

通过重写这些方法,可以实现复杂的触摸事件处理逻辑,例如在特定条件下阻止子视图接收触摸事件,或者改变事件的处理顺序。

自定义视图的性能优化

减少绘制次数

  1. 使用invalidatepostInvalidate:尽量精确地调用invalidate方法,只刷新需要更新的部分。例如,如果只是视图的某个小区域发生变化,不要调用invalidate()来刷新整个视图,而是使用invalidate(Rect)方法来指定刷新区域。postInvalidate用于在非UI线程中请求视图刷新。
  2. 合并绘制操作:在onDraw方法中,尽量合并相似的绘制操作。比如,将多个使用相同画笔的图形绘制操作放在一起,避免频繁创建和销毁Paint对象。

优化测量和布局

  1. 缓存测量结果:如果视图的大小不经常变化,可以缓存测量结果,避免每次都重新计算。在onMeasure方法中,可以通过一个标志位来判断是否需要重新测量。
  2. 合理使用wrap_content:在自定义视图中,使用wrap_content时要谨慎,因为它可能会导致父视图多次测量。尽量在视图设计时考虑到这种情况,通过合理的默认尺寸或其他方式来减少测量次数。

避免内存泄漏

  1. 正确处理资源引用:在自定义视图中,如果引用了资源,如图片、动画等,要确保在视图销毁时正确释放这些资源。例如,在onDetachedFromWindow方法中取消动画、释放图片资源等操作。
  2. 避免内部类持有外部视图引用:如果在自定义视图中使用内部类,要注意避免内部类持有外部视图的强引用,防止在视图销毁后内部类仍然存活,导致内存泄漏。可以使用弱引用或静态内部类来解决这个问题。

通过以上对Kotlin Android自定义视图的详细介绍,从基础概念到实际代码实现,再到性能优化等方面,希望开发者能够全面掌握自定义视图技术,打造出更加丰富和高效的Android应用界面。