close

在多年前,曾寫過這篇的我

Willy's Fish教學筆記』 Android Canvas 繪圖 介紹 教學 使用

 

今天就要利用裡面提到的相關方法,來實作一個 custom view

這次的 UI 需求是像漫畫一樣的對話框,如下圖

截圖 2020-09-28 下午6.38.02

 

原始碼可以在這裡看

https://github.com/WillysFish/ArrowTooltip

我把他包成一個庫,讓大家可以使用

使用方法如下:

在 root build.gradle 加入

allprojects {
    repositories {
        。。。
        maven { url 'https://jitpack.io' }
    }
}

 

然後 implementation

implementation 'com.github.WillysFish:ArrowTooltip:1.0.0'

 

======================================================

在引入 library 之後,我們可以到 github 查看相關的使用方式

在這裡我們就先聚焦在裡面是如何實作的

首先,這是一個用 ConstraintLayout 做成的 view

class ArrowTooltipLayout @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr) {}

 

我們會畫出背景樣式

然後包一些我們想要 show 的 view 在裡面

用法就和一般的 ConstraintLayout 一樣的方便

 

再來,我們會利用 attrs.xml 來定義我們需要的自訂參數,如下

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="ArrowTooltipLayout">
        <attr name="arrowOrientation" format="enum">
            <enum name="top" value="0" />
            <enum name="bottom" value="1" />
            <enum name="start" value="2" />
            <enum name="end" value="3" />
        </attr>
        。。。
        <attr name="arrowHeight" format="dimension" />
        <attr name="arrowWidth" format="dimension" />
    </declare-styleable>
</resources>

 

在 Layout 的開頭,可以去取得我們剛剛自訂的參數

// 取得自訂參數
private val attributes by lazy {
    context.obtainStyledAttributes(
        attrs, R.styleable.ArrowTooltipLayout)
}

// 顏色
private val tipLayoutColor =
    attributes.getColor(
        R.styleable.ArrowTooltipLayout_tipLayoutColor, Color.GRAY)
。。。

 

在畫畫之前,還有最後一步要做

就是決定 view 的大小

override fun onMeasure(
               widthMeasureSpec: Int, heightMeasureSpec: Int) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec)

    val minW: Int = 
             paddingLeft + paddingRight + suggestedMinimumWidth
    val w: Int = 
             View.resolveSizeAndState(minW, widthMeasureSpec, 0)

    val minH: Int = 
            paddingBottom + paddingTop + suggestedMinimumHeight
    val h: Int = 
            View.resolveSizeAndState(minH, heightMeasureSpec, 0)

    setMeasuredDimension(w, h)
}

 

這裡面我們先定了最小 size

然後利用 resolveSizeAndState() 來算出最後的 size

那為什麼要用 resolveSizeAndState() 呢?

比如說我們常用的 wrap_content、match_parent

resolveSizeAndState() 會將我們可接受的最小值與 MeasureSpec of Parent 做計算

決定出最後的大小

 

在設定完 size 之後

終於,最後一步,我們可以來畫我們想要的對話框了

畫面主要是由 onDraw() 來完成

override fun onDraw(canvas: Canvas?) {
    super.onDraw(canvas)
    canvas?.also { c ->
        // 箭頭方向
        when (arrowOrientation) {
            ArrowOrientation.BOTTOM -> drawBottomArrowLayout(c)
            ArrowOrientation.START -> drawLeftArrowLayout(c)
            ArrowOrientation.END -> drawRightArrowLayout(c)
            else -> drawTopArrowLayout(c)
        }
    }
}

 

由於我們有箭頭方向的參數

所以畫的方式會不同,這裡以箭頭在下的情況來解說

private fun drawBottomArrowLayout(canvas: Canvas) {
    // 起點
    val startX = if (specificX > 0) specificX 
                 else measuredWidth * positionPercentage
    val startY = measuredHeight.toFloat()

    // 四個角
    val topRightX = measuredWidth.toFloat()
    val topRightY = 0F
    val bottomRightX = measuredWidth.toFloat()
    val bottomRightY = measuredHeight - arrowH.toFloat()
    val bottomLeftX = 0F
    val bottomLeftY = measuredHeight - arrowH.toFloat()
    val topLeftX = 0F
    val topLeftY = 0F

    // 三角點 1 & 2 (依起點順時針)
    val triangleX1 = startX - arrowW / 2F
    val triangleY1 = startY - arrowH.toFloat()
    val triangleX2 = startX + arrowW / 2F
    val triangleY2 = startY - arrowH.toFloat()

    // 設置 Path
    path.moveTo(startX, startY)
    path.lineTo(triangleX1, triangleY1)
    path.lineTo(bottomLeftX + tipLayoutRadius, bottomLeftY)
    path.cubicTo(
        bottomLeftX + tipLayoutRadius, bottomLeftY,
        bottomLeftX, bottomLeftY,
        bottomLeftX, bottomLeftY - tipLayoutRadius
    )
    path.lineTo(topLeftX, topLeftY + tipLayoutRadius)
    path.cubicTo(
        topLeftX, topLeftY + tipLayoutRadius,
        topLeftX, topLeftY,
        topLeftX + tipLayoutRadius, topLeftY
    )
    path.lineTo(topRightX - tipLayoutRadius, topRightY)
    path.cubicTo(
        topRightX - tipLayoutRadius, topRightY,
        topRightX, topRightY,
        topRightX, topRightY + tipLayoutRadius
    )
    path.lineTo(bottomRightX, bottomRightY - tipLayoutRadius)
    path.cubicTo(
        bottomRightX, bottomRightY - tipLayoutRadius,
        bottomRightX, bottomRightY,
        bottomRightX - tipLayoutRadius, bottomRightY
    )
    path.lineTo(triangleX2, triangleY2)
    path.close()

    canvas.drawPath(path, paint)
}

 

這裡我們介紹幾個東西

1、measuredHeight、measuredWidth 是我們這個 view 的實際大小

2、cubicTo() 我們給予 3 個座標,讓其畫出貝茲曲線 (導角 radius)

利用上面這些東西

我們可以算出每個需要的座標點

然後描成一個 Path

最後再用 Canvas.drawPath 就可以呈現畫面了

完成 !!

 

等一下,run 過之後我們會發現剛剛畫的東西,都沒有出現

onDraw() 並沒有被調用

這時候要加上這句,來解鎖 Draw 功能

init {
    setWillNotDraw(false)
}

 

因為 ViewGroup 在一般情況下

出於性能考量,會被設置成 WILL_NOT_DROW,這樣,ondraw 就不會被執行

所以我們設定為 false,onDraw 才會被調用

這麼一來就 OK 了,一個我們自己畫的 View 就完成了

 

 

 

 

參考資料:

https://developer.android.com/training/custom-views/custom-drawing

https://github.com/WillysFish/ArrowTooltip

 


arrow
arrow

    顏澤偉 發表在 痞客邦 留言(0) 人氣()