/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.wear.widget
import android.content.Context
import android.content.res.ColorStateList
import android.content.res.Resources
import android.content.res.TypedArray
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Path
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.graphics.Rect
import android.graphics.Typeface
import android.os.Build
import android.text.StaticLayout
import android.text.TextPaint
import android.text.TextUtils
import android.util.AttributeSet
import android.view.View
import androidx.annotation.RequiresApi
import androidx.wear.R
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.min
import kotlin.math.sin
import kotlin.properties.Delegates
/**
* A WearCurvedTextView is a component allowing developers to easily write curved text following
* the curvature of the largest circle that can be inscribed in the view. WearArcLayout could be
* used to concatenate multiple curved texts, also layout together with other widgets such as icons.
*/
public class WearCurvedTextView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = android.R.attr.textViewStyle,
defStyleRes: Int = 0
) : View(context, attrs, defStyleAttr, defStyleRes), WearArcLayout.ArcLayoutWidget {
private val path = Path()
private val bgPath = Path()
private val paint = TextPaint().apply { setAntiAlias(true) }
private val bounds = Rect()
private val bgBounds = Rect()
private var dirty = true
private var textToDraw: String = ""
private var pathRadius: Float = 0f
private var textSweepDegrees: Float = 0f
private var backgroundSweepDegrees: Float = 359.9f
private var parentClockwise: Boolean? = null
private var lastUsedTextAlignment = -1
private var localRotateAngle = 0f
private var parentRotateAngle = 0f
private companion object {
// make 0 degree at 12 o'clock, since canvas assumes 0 degree is 3 o'clock
private const val ANCHOR_DEGREE_OFFSET = -90f
private const val UNSET_ANCHOR_DEGREE = -1f
private const val UNSET_ANCHOR_TYPE = -1
private const val UNSET_SWEEP_DEGREE = -1f
private const val DEFAULT_TEXT_SIZE = 24f
private const val DEFAULT_TEXT_COLOR = Color.WHITE
private const val DEFAULT_TEXT_STYLE = Typeface.NORMAL
private const val DEFAULT_CLOCKWISE = true
private const val FONT_WEIGHT_MAX = 1000
private const val ITALIC_SKEW_X = -0.25f
}
private fun doUpdate() {
dirty = true
updatePaint()
requestLayout()
postInvalidate()
}
private fun doRedraw() {
dirty = true
postInvalidate()
}
/**
* Change of the value triggers paint update, re-layout and re-draw
*/
private fun <T> makeUpdateDelegate(v: T) =
Delegates.observable(v) { _, o, n -> if (n != o) doUpdate() }
/**
* Change of the value triggers re-draw
*/
private fun <T> makeRedrawDelegate(v: T) =
Delegates.observable(v) { _, o, n -> if (n != o) doRedraw() }
public var anchorType: Int by makeUpdateDelegate(UNSET_ANCHOR_TYPE)
public var anchorAngleDegrees: Float by makeRedrawDelegate(UNSET_ANCHOR_DEGREE)
public var sweepDegrees: Float by makeUpdateDelegate(UNSET_SWEEP_DEGREE)
public var text: String by makeUpdateDelegate("")
public var textSize: Float by makeUpdateDelegate(DEFAULT_TEXT_SIZE)
public var typeface: Typeface? by makeUpdateDelegate(null)
public var clockwise: Boolean by makeUpdateDelegate(DEFAULT_CLOCKWISE)
public var textColor: Int by makeRedrawDelegate(DEFAULT_TEXT_COLOR)
public var ellipsize: TextUtils.TruncateAt? by makeRedrawDelegate(null)
public var letterSpacing: Float by makeUpdateDelegate(0f)
public var fontFeatureSettings: String? by makeUpdateDelegate(null)
public var fontVariationSettings: String? by makeUpdateDelegate(null)
override fun getSweepAngleDegrees(): Float = backgroundSweepDegrees
override fun getThicknessPx(): Int =
(paint.fontMetrics.descent - paint.fontMetrics.ascent).toInt()
/**
* @throws IllegalArgumentException if the anchorType and/or anchorAngleDegrees attributes
* were set for a widget in WearArcLayout
*/
override fun checkInvalidAttributeAsChild(clockwise: Boolean) {
parentClockwise = clockwise
if (anchorType != UNSET_ANCHOR_TYPE) {
throw IllegalArgumentException(
"WearCurvedTextView shall not set anchorType value when added into WearArcLayout"
)
}
if (anchorAngleDegrees != UNSET_ANCHOR_DEGREE) {
throw IllegalArgumentException(
"WearCurvedTextView shall not set anchorAngleDegrees value when added into " +
"WearArcLayout"
)
}
}
override fun handleLayoutRotate(angle: Float): Boolean {
parentRotateAngle = angle
return true
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
doUpdate()
}
private fun updatePaint() {
paint.textSize = textSize
paint.getTextBounds(text, 0, text.length, bounds)
// Note that ascent is negative.
pathRadius = min(width, height) / 2f +
if (clockwise) paint.fontMetrics.ascent - paddingTop
else -paint.fontMetrics.descent - paddingBottom.toFloat()
textSweepDegrees =
(getWidthSelf() / pathRadius / Math.PI.toFloat() * 180f).coerceAtMost(360f)
backgroundSweepDegrees =
if (sweepDegrees == UNSET_SWEEP_DEGREE) textSweepDegrees
else sweepDegrees
}
private fun getWidthSelf() = bounds.width().toFloat() + paddingLeft + paddingRight
private fun ellipsize(ellipsizedWidth: Int): String {
val layoutBuilder =
StaticLayout.Builder.obtain(text, 0, text.length, paint, ellipsizedWidth)
layoutBuilder.setEllipsize(ellipsize)
layoutBuilder.setMaxLines(1)
val layout = layoutBuilder.build()
// Cut text that it's too big even if no ellipsize mode is provided.
if (ellipsize == null) {
return text.substring(0, layout.getLineEnd(0))
}
val ellipsisCount = layout.getEllipsisCount(0)
if (ellipsisCount == 0) {
return text
}
val ellipsisStart = layout.getEllipsisStart(0)
return text.replaceRange(ellipsisStart, ellipsisStart + ellipsisCount, "\u2026")
}
private fun updatePathsIfNeeded(withBackground: Boolean) {
// The dirty flag is not set when properties we inherit from View are modified
if (!dirty && textAlignment == lastUsedTextAlignment) {
return
}
dirty = false
lastUsedTextAlignment = textAlignment
paint.textSize = textSize
val maxSweepDegrees = if (sweepDegrees == UNSET_SWEEP_DEGREE) 360f else sweepDegrees
if (textSweepDegrees <= maxSweepDegrees) {
textToDraw = text
} else {
textToDraw = ellipsize(
(maxSweepDegrees / 180f * Math.PI * pathRadius - paddingLeft - paddingRight).toInt()
)
textSweepDegrees = maxSweepDegrees
}
val clockwiseFactor = if (clockwise) 1f else -1f
val parentClockwiseFactor = if (parentClockwise ?: clockwise) 1f else -1f
val alignmentFactor = when (textAlignment) {
TEXT_ALIGNMENT_TEXT_START -> 0f
TEXT_ALIGNMENT_VIEW_START -> 0f
TEXT_ALIGNMENT_TEXT_END -> 1f
TEXT_ALIGNMENT_VIEW_END -> 1f
else -> 0.5f // TEXT_ALIGNMENT_CENTER
}
val anchorTypeFactor = when (anchorType) {
WearArcLayout.ANCHOR_START -> 0f
WearArcLayout.ANCHOR_CENTER -> 0.5f
WearArcLayout.ANCHOR_END -> 1f
else -> if (parentClockwiseFactor == clockwiseFactor) 0f else -1f
}
val actualAnchorDegree =
(if (anchorAngleDegrees == UNSET_ANCHOR_DEGREE) 0f else anchorAngleDegrees) +
ANCHOR_DEGREE_OFFSET
// Always draw the curved text on top center, then rotate the canvas to the right position
val backgroundStartAngle = - 0.5f * backgroundSweepDegrees + ANCHOR_DEGREE_OFFSET
localRotateAngle = (
actualAnchorDegree -
parentClockwiseFactor * anchorTypeFactor * backgroundSweepDegrees -
backgroundStartAngle
)
val textStartAngle = backgroundStartAngle + clockwiseFactor * (
alignmentFactor * (backgroundSweepDegrees - textSweepDegrees) +
paddingLeft / pathRadius / Math.PI * 180
).toFloat()
val centerX = width / 2f
val centerY = height / 2f
path.reset()
path.addArc(
centerX - pathRadius,
centerY - pathRadius,
centerX + pathRadius,
centerY + pathRadius,
textStartAngle,
clockwiseFactor * textSweepDegrees
)
if (withBackground) {
bgPath.reset()
val radius1 = pathRadius - clockwiseFactor * paint.fontMetrics.descent
val radius2 = pathRadius - clockwiseFactor * paint.fontMetrics.ascent
bgPath.arcTo(
centerX - radius2,
centerY - radius2,
centerX + radius2,
centerY + radius2,
backgroundStartAngle,
clockwiseFactor * backgroundSweepDegrees, false
)
bgPath.arcTo(
centerX - radius1,
centerY - radius1,
centerX + radius1,
centerY + radius1,
backgroundStartAngle + clockwiseFactor * backgroundSweepDegrees,
-clockwiseFactor * backgroundSweepDegrees, false
)
bgPath.close()
val angle1 = backgroundStartAngle
val angle2 = backgroundStartAngle + clockwiseFactor * backgroundSweepDegrees
val pointsRadial =
listOf(radius1, radius2).flatMap { listOf(it to angle1, it to angle2) }
val x = pointsRadial.map { (r, a) -> (centerX + r * cos(a * PI / 180)).toInt() }
val y = pointsRadial.map { (r, a) -> (centerY + r * sin(a * PI / 180)).toInt() }
bgBounds.left = x.minOrNull()!!.toInt()
bgBounds.top = (centerY - radius2).toInt() // 0 degree angle value to cover the arc top
bgBounds.right = x.maxOrNull()!!.toInt()
bgBounds.bottom = y.maxOrNull()!!.toInt()
}
}
override fun draw(canvas: Canvas) {
canvas.save()
var withBackground = getBackground() != null
updatePathsIfNeeded(withBackground)
canvas.rotate(localRotateAngle + parentRotateAngle, width / 2f, height / 2f)
if (withBackground) {
canvas.clipPath(bgPath)
getBackground().setBounds(bgBounds)
}
super.draw(canvas)
canvas.restore()
}
private fun getTintFilter(): PorterDuffColorFilter? {
val tintColor = getBackgroundTintList()
val tintMode = getBackgroundTintMode() ?: PorterDuff.Mode.SRC_IN
if (tintColor == null) {
return null
}
val color = tintColor.getDefaultColor()
return PorterDuffColorFilter(color, tintMode)
}
override fun onDraw(canvas: Canvas) {
paint.color = textColor
paint.style = Paint.Style.FILL
canvas.drawTextOnPath(textToDraw, path, 0f, 0f, paint)
}
/**
* Sets the Typeface taking into account the given attributes.
*
* @param familyName family name string, e.g. "serif"
* @param typefaceIndex an index of the typeface enum, e.g. SANS, SERIF.
* @param style a typeface style
* @param weight a weight value for the Typeface or -1 if not specified.
*/
private fun setTypefaceFromAttrs(
familyName: String?,
typefaceIndex: Int,
style: Int,
weight: Int
) {
// typeface is ignored when font family is set
val computedTypeface = familyName?.let { Typeface.create(familyName, Typeface.NORMAL) }
?: when (typefaceIndex) {
1 -> Typeface.SANS_SERIF
2 -> Typeface.SERIF
3 -> Typeface.MONOSPACE
else -> null
}
resolveStyleAndSetTypeface(computedTypeface, style, weight)
}
private fun resolveStyleAndSetTypeface(tf: Typeface?, style: Int, weight: Int) {
if (weight >= 0 && Build.VERSION.SDK_INT >= 28) {
val _weight = min(FONT_WEIGHT_MAX, weight)
val italic = (style and Typeface.ITALIC) != 0
typeface = Api28Impl.createTypeface(tf, _weight, italic)
paint.setTypeface(typeface)
} else {
setTypeface(tf, style)
}
}
/**
* Sets the typeface and style in which the text should be displayed, and turns on the fake
* bold and italic bits in the Paint if the Typeface that you provided does not have all the
* bits in the style that you specified.
*/
private fun setTypeface(tf: Typeface?, style: Int) {
if (style > 0) {
var _tf = tf?.let { Typeface.create(it, style) } ?: Typeface.defaultFromStyle(style)
if (paint.typeface != _tf) {
paint.typeface = _tf
typeface = _tf
}
// now compute what (if any) algorithmic styling is needed
val typefaceStyle: Int = _tf?.style ?: 0
val need: Int = style and typefaceStyle.inv() // style & ~typefaceStyle
paint.isFakeBoldText = (need and Typeface.BOLD) != 0
paint.textSkewX = if ((need and Typeface.ITALIC) != 0) ITALIC_SKEW_X else 0f
} else {
paint.isFakeBoldText = false
paint.textSkewX = 0f
if (paint.typeface != tf) {
paint.typeface = tf
}
}
}
/**
* Set of attribute that can be defined in a Text Appearance.
*/
private class TextAppearanceAttributes {
var textColor: ColorStateList? = null
var textSize: Float = DEFAULT_TEXT_SIZE
var fontFamily: String? = null
var fontFamilyExplicit: Boolean = false
var fontTypeface: Typeface? = null
var typefaceIndex: Int = -1
var textStyle: Int = DEFAULT_TEXT_STYLE
var fontWeight: Int = -1
var letterSpacing: Float = 0f
var fontFeatureSettings: String? = null
var fontVariationSettings: String? = null
}
/**
* Sets the textColor, size, style, font etc from the specified TextAppearanceAttributes
*/
private fun applyTextAppearance(attributes: TextAppearanceAttributes) {
attributes.textColor ?. let { textColor = it.getDefaultColor() }
if (attributes.textSize != -1f) {
textSize = attributes.textSize
}
setTypefaceFromAttrs(
attributes.fontFamily,
attributes.typefaceIndex,
attributes.textStyle,
attributes.fontWeight
)
paint.setLetterSpacing(attributes.letterSpacing)
letterSpacing = attributes.letterSpacing
paint.setFontFeatureSettings(attributes.fontFeatureSettings)
fontFeatureSettings = attributes.fontFeatureSettings
if (Build.VERSION.SDK_INT >= 26 ) {
Api26Impl.paintSetFontVariationSettings(paint, attributes.fontVariationSettings)
}
fontVariationSettings = attributes.fontVariationSettings
}
/**
* Read the Text Appearance attributes from a given TypedArray and set its values to the
* given set. If the TypedArray contains a value that already set in the given attributes,
* that will be overridden.
*/
private fun readTextAppearance(
appearance: TypedArray,
attributes: TextAppearanceAttributes,
isTextAppearance: Boolean
) {
appearance.apply {
getColorStateList(
if (isTextAppearance) R.styleable.TextAppearance_android_textColor
else R.styleable.WearCurvedTextView_android_textColor
) ?. let { color ->
attributes.textColor = color
}
attributes.textSize = getDimension(
if (isTextAppearance) R.styleable.TextAppearance_android_textSize
else R.styleable.WearCurvedTextView_android_textSize,
attributes.textSize
)
attributes.textStyle = getInt(
if (isTextAppearance) R.styleable.TextAppearance_android_textStyle
else R.styleable.WearCurvedTextView_android_textStyle,
attributes.textStyle
)
// make sure that the typeface attribute is read before fontFamily attribute
attributes.typefaceIndex = getInt(
if (isTextAppearance) R.styleable.TextAppearance_android_typeface
else R.styleable.WearCurvedTextView_android_typeface,
attributes.typefaceIndex
)
if (attributes.typefaceIndex != -1 && !attributes.fontFamilyExplicit) {
attributes.fontFamily = null
}
var attr = if (isTextAppearance) R.styleable.TextAppearance_android_fontFamily
else R.styleable.WearCurvedTextView_android_fontFamily
if (hasValue(attr)) {
attributes.fontFamily = getString(attr)
attributes.fontFamilyExplicit = !isTextAppearance
}
attributes.fontWeight = getInt(
if (isTextAppearance) R.styleable.TextAppearance_android_textFontWeight
else R.styleable.WearCurvedTextView_android_textFontWeight,
attributes.fontWeight
)
attributes.letterSpacing = getFloat(
if (isTextAppearance) R.styleable.TextAppearance_android_letterSpacing
else R.styleable.WearCurvedTextView_android_letterSpacing,
attributes.letterSpacing
)
getString(
if (isTextAppearance) R.styleable.TextAppearance_android_fontFeatureSettings
else R.styleable.WearCurvedTextView_android_fontFeatureSettings
) ?. let { value ->
attributes.fontFeatureSettings = value
}
getString(
if (isTextAppearance) R.styleable.TextAppearance_android_fontVariationSettings
else R.styleable.WearCurvedTextView_android_fontVariationSettings
) ?. let { value ->
attributes.fontVariationSettings = value
}
}
}
init {
val attributes = TextAppearanceAttributes()
attributes.textColor = ColorStateList.valueOf(DEFAULT_TEXT_COLOR)
val theme: Resources.Theme = context.theme
var a: TypedArray = theme.obtainStyledAttributes(
attrs, R.styleable.TextViewAppearance, defStyleAttr, defStyleRes
)
var appearance: TypedArray? = null
val ap: Int = a.getResourceId(R.styleable.TextViewAppearance_android_textAppearance, -1)
a.recycle()
if (ap != -1) {
appearance = theme.obtainStyledAttributes(ap, R.styleable.TextAppearance)
}
if (appearance != null) {
readTextAppearance(appearance, attributes, true)
appearance.recycle()
}
context.obtainStyledAttributes(
attrs, R.styleable.WearCurvedTextView, defStyleAttr, defStyleRes
).apply {
// overrride the value in the appearance with explicitly specified attribute values
readTextAppearance(this, attributes, false)
// read the other supported TextView attributes
text = getString(R.styleable.WearCurvedTextView_android_text) ?: ""
val textEllipsize = getInt(R.styleable.WearCurvedTextView_android_ellipsize, 0)
ellipsize = when (textEllipsize) {
1 -> TextUtils.TruncateAt.START
2 -> TextUtils.TruncateAt.MIDDLE
3 -> TextUtils.TruncateAt.END
else -> null
}
// read the custom WearCurvedTextView attributes
sweepDegrees = getFloat(R.styleable.WearCurvedTextView_sweepDegrees, UNSET_SWEEP_DEGREE)
anchorType = getInt(R.styleable.WearCurvedTextView_anchorPosition, UNSET_ANCHOR_TYPE)
anchorAngleDegrees =
getFloat(R.styleable.WearCurvedTextView_anchorAngleDegrees, UNSET_ANCHOR_DEGREE)
clockwise = getBoolean(R.styleable.WearCurvedTextView_clockwise, DEFAULT_CLOCKWISE)
recycle()
}
applyTextAppearance(attributes)
}
/**
* Nested class to avoid verification errors for methods induces in API level 26
*/
@RequiresApi(26)
private object Api26Impl {
fun paintSetFontVariationSettings(paint: Paint, fontVariationSettings: String?) {
paint.setFontVariationSettings(fontVariationSettings)
}
}
/**
* Nested class to avoid verification errors for methods induces in API level 28
*/
@RequiresApi(28)
private object Api28Impl {
fun createTypeface(family: Typeface?, weight: Int, italic: Boolean): Typeface {
return Typeface.create(family, weight, italic)
}
}
}