1 /* 2 * Copyright (C) 2020 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 package com.android.deskclock.widget 17 18 import android.annotation.SuppressLint 19 import android.content.Context 20 import android.graphics.Canvas 21 import android.graphics.Color 22 import android.graphics.Paint 23 import android.util.AttributeSet 24 import android.util.Property 25 import android.view.Gravity 26 import android.view.View 27 28 import com.android.deskclock.R 29 30 import kotlin.math.min 31 32 /** 33 * A [View] that draws primitive circles. 34 */ 35 class CircleView @JvmOverloads constructor( 36 context: Context, 37 attrs: AttributeSet? = null, 38 defStyleAttr: Int = 0 39 ) : View(context, attrs, defStyleAttr) { 40 /** The [Paint] used to draw the circle. */ 41 private val mCirclePaint = Paint() 42 43 /** the current [Gravity] used to align/size the circle */ 44 var gravity: Int 45 private set 46 47 private var mCenterX: Float 48 private var mCenterY: Float 49 50 /** the radius of the circle */ 51 var radius: Float 52 private set 53 54 init { 55 val a = context.obtainStyledAttributes(attrs, R.styleable.CircleView, defStyleAttr, 0) 56 57 gravity = a.getInt(R.styleable.CircleView_android_gravity, Gravity.NO_GRAVITY) 58 mCenterX = a.getDimension(R.styleable.CircleView_centerX, 0.0f) 59 mCenterY = a.getDimension(R.styleable.CircleView_centerY, 0.0f) 60 radius = a.getDimension(R.styleable.CircleView_radius, 0.0f) 61 62 mCirclePaint.color = a.getColor(R.styleable.CircleView_fillColor, Color.WHITE) 63 64 a.recycle() 65 } 66 onRtlPropertiesChangednull67 override fun onRtlPropertiesChanged(layoutDirection: Int) { 68 super.onRtlPropertiesChanged(layoutDirection) 69 70 if (gravity != Gravity.NO_GRAVITY) { 71 applyGravity(gravity, layoutDirection) 72 } 73 } 74 onLayoutnull75 override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { 76 super.onLayout(changed, left, top, right, bottom) 77 78 if (gravity != Gravity.NO_GRAVITY) { 79 applyGravity(gravity, layoutDirection) 80 } 81 } 82 onDrawnull83 override fun onDraw(canvas: Canvas) { 84 super.onDraw(canvas) 85 86 // draw the circle, duh 87 canvas.drawCircle(mCenterX, mCenterY, radius, mCirclePaint) 88 } 89 hasOverlappingRenderingnull90 override fun hasOverlappingRendering(): Boolean { 91 // only if we have a background, which we shouldn't... 92 return background != null 93 } 94 95 /** 96 * Describes how to align/size the circle relative to the view's bounds. Defaults to 97 * [Gravity.NO_GRAVITY]. 98 * 99 * Note: using [.setCenterX], [.setCenterY], or 100 * [.setRadius] will automatically clear any conflicting gravity bits. 101 * 102 * @param gravity the [Gravity] flags to use 103 * @return this object, allowing calls to methods in this class to be chained 104 * @see R.styleable.CircleView_android_gravity 105 */ setGravitynull106 fun setGravity(gravity: Int): CircleView { 107 if (this.gravity != gravity) { 108 this.gravity = gravity 109 110 if (gravity != Gravity.NO_GRAVITY && isLayoutDirectionResolved) { 111 applyGravity(gravity, layoutDirection) 112 } 113 } 114 return this 115 } 116 117 /** 118 * @return the ARGB color used to fill the circle 119 */ 120 val fillColor: Int 121 get() = mCirclePaint.color 122 123 /** 124 * Sets the ARGB color used to fill the circle and invalidates only the affected area. 125 * 126 * @param color the ARGB color to use 127 * @return this object, allowing calls to methods in this class to be chained 128 * @see R.styleable.CircleView_fillColor 129 */ setFillColornull130 fun setFillColor(color: Int): CircleView { 131 if (mCirclePaint.color != color) { 132 mCirclePaint.color = color 133 134 // invalidate the current area 135 invalidate(mCenterX, mCenterY, radius) 136 } 137 return this 138 } 139 140 /** 141 * Sets the x-coordinate for the center of the circle and invalidates only the affected area. 142 * 143 * @param centerX the x-coordinate to use, relative to the view's bounds 144 * @return this object, allowing calls to methods in this class to be chained 145 * @see R.styleable.CircleView_centerX 146 */ setCenterXnull147 fun setCenterX(centerX: Float): CircleView { 148 val oldCenterX = mCenterX 149 if (oldCenterX != centerX) { 150 mCenterX = centerX 151 152 // invalidate the old/new areas 153 invalidate(oldCenterX, mCenterY, radius) 154 invalidate(centerX, mCenterY, radius) 155 } 156 157 // clear the horizontal gravity flags 158 gravity = gravity and Gravity.HORIZONTAL_GRAVITY_MASK.inv() 159 160 return this 161 } 162 163 /** 164 * Sets the y-coordinate for the center of the circle and invalidates only the affected area. 165 * 166 * @param centerY the y-coordinate to use, relative to the view's bounds 167 * @return this object, allowing calls to methods in this class to be chained 168 * @see R.styleable.CircleView_centerY 169 */ setCenterYnull170 fun setCenterY(centerY: Float): CircleView { 171 val oldCenterY = mCenterY 172 if (oldCenterY != centerY) { 173 mCenterY = centerY 174 175 // invalidate the old/new areas 176 invalidate(mCenterX, oldCenterY, radius) 177 invalidate(mCenterX, centerY, radius) 178 } 179 180 // clear the vertical gravity flags 181 gravity = gravity and Gravity.VERTICAL_GRAVITY_MASK.inv() 182 183 return this 184 } 185 186 /** 187 * Sets the radius of the circle and invalidates only the affected area. 188 * 189 * @param radius the radius to use 190 * @return this object, allowing calls to methods in this class to be chained 191 * @see R.styleable.CircleView_radius 192 */ setRadiusnull193 fun setRadius(radius: Float): CircleView { 194 val oldRadius = this.radius 195 if (oldRadius != radius) { 196 this.radius = radius 197 198 // invalidate the old/new areas 199 invalidate(mCenterX, mCenterY, oldRadius) 200 if (radius > oldRadius) { 201 invalidate(mCenterX, mCenterY, radius) 202 } 203 } 204 205 // clear the fill gravity flags 206 if (gravity and Gravity.FILL_HORIZONTAL == Gravity.FILL_HORIZONTAL) { 207 gravity = gravity and Gravity.FILL_HORIZONTAL.inv() 208 } 209 if (gravity and Gravity.FILL_VERTICAL == Gravity.FILL_VERTICAL) { 210 gravity = gravity and Gravity.FILL_VERTICAL.inv() 211 } 212 213 return this 214 } 215 216 /** 217 * Invalidates the rectangular area that circumscribes the circle defined by `centerX`, 218 * `centerY`, and `radius`. 219 */ invalidatenull220 private fun invalidate(centerX: Float, centerY: Float, radius: Float) { 221 invalidate((centerX - radius - 0.5f).toInt(), (centerY - radius - 0.5f).toInt(), 222 (centerX + radius + 0.5f).toInt(), (centerY + radius + 0.5f).toInt()) 223 } 224 225 /** 226 * Applies the specified `gravity` and `layoutDirection`, adjusting the alignment 227 * and size of the circle depending on the resolved [Gravity] flags. Also invalidates the 228 * affected area if necessary. 229 * 230 * @param gravity the [Gravity] the [Gravity] flags to use 231 * @param layoutDirection the layout direction used to resolve the absolute gravity 232 */ 233 @SuppressLint("RtlHardcoded") applyGravitynull234 private fun applyGravity(gravity: Int, layoutDirection: Int) { 235 val absoluteGravity = Gravity.getAbsoluteGravity(gravity, layoutDirection) 236 237 val oldRadius = radius 238 val oldCenterX = mCenterX 239 val oldCenterY = mCenterY 240 241 when (absoluteGravity and Gravity.HORIZONTAL_GRAVITY_MASK) { 242 Gravity.LEFT -> mCenterX = 0.0f 243 Gravity.CENTER_HORIZONTAL, Gravity.FILL_HORIZONTAL -> mCenterX = width / 2.0f 244 Gravity.RIGHT -> mCenterX = width.toFloat() 245 } 246 247 when (absoluteGravity and Gravity.VERTICAL_GRAVITY_MASK) { 248 Gravity.TOP -> mCenterY = 0.0f 249 Gravity.CENTER_VERTICAL, Gravity.FILL_VERTICAL -> mCenterY = height / 2.0f 250 Gravity.BOTTOM -> mCenterY = height.toFloat() 251 } 252 253 when (absoluteGravity and Gravity.FILL) { 254 Gravity.FILL -> radius = min(width, height) / 2.0f 255 Gravity.FILL_HORIZONTAL -> radius = width / 2.0f 256 Gravity.FILL_VERTICAL -> radius = height / 2.0f 257 } 258 259 if (oldCenterX != mCenterX || oldCenterY != mCenterY || oldRadius != radius) { 260 invalidate(oldCenterX, oldCenterY, oldRadius) 261 invalidate(mCenterX, mCenterY, radius) 262 } 263 } 264 265 companion object { 266 /** 267 * A Property wrapper around the fillColor functionality handled by the 268 * [.setFillColor] and [.getFillColor] methods. 269 */ 270 @JvmField 271 val FILL_COLOR: Property<CircleView, Int> = 272 object : Property<CircleView, Int>(Int::class.java, "fillColor") { getnull273 override fun get(view: CircleView): Int { 274 return view.fillColor 275 } 276 setnull277 override fun set(view: CircleView, value: Int) { 278 view.setFillColor(value) 279 } 280 } 281 282 /** 283 * A Property wrapper around the radius functionality handled by the 284 * [.setRadius] and [.getRadius] methods. 285 */ 286 @JvmField 287 val RADIUS: Property<CircleView, Float> = 288 object : Property<CircleView, Float>(Float::class.java, "radius") { getnull289 override fun get(view: CircleView): Float { 290 return view.radius 291 } 292 setnull293 override fun set(view: CircleView, value: Float) { 294 view.setRadius(value) 295 } 296 } 297 } 298 }