1 /* 2 * Copyright (C) 2024 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 17 package com.android.systemui.battery.unified 18 19 import android.content.Context 20 import android.graphics.Canvas 21 import android.graphics.Matrix 22 import android.graphics.Rect 23 import android.graphics.RectF 24 import android.graphics.Typeface 25 import android.graphics.drawable.Drawable 26 import android.graphics.drawable.LayerDrawable 27 import android.util.PathParser 28 import android.view.Gravity 29 import android.view.View 30 import com.android.systemui.res.R 31 import kotlin.math.ceil 32 import kotlin.math.floor 33 import kotlin.math.roundToInt 34 35 /** 36 * Custom [Drawable] that manages a list of other drawables which, together, achieve an appropriate 37 * view for [BatteryDrawableState]. 38 * 39 * The main elements managed by this drawable are: 40 * 41 * 1. A battery frame background, which may show a solid fill color 42 * 2. The battery frame itself 43 * 3. A custom [BatteryFillDrawable], which renders a fill level, appropriately scale and 44 * clipped to the battery percent 45 * 4. Percent text 46 * 5. An attribution 47 * 48 * Layers (1) and (2) are loaded directly from xml, as they are static assets. Layer (3) contains a 49 * custom [Drawable.draw] implementation and uses the same path as the battery shape to achieve an 50 * appropriate fill shape. 51 * 52 * The text and attribution layers have the following behaviors: 53 * 54 * - When text-only or attribute-only, the foreground layer is centered and the maximum size 55 * - When sharing space between the attribute and the text: 56 * - The internal space is divided into 12x10 and 6x6 rectangles 57 * - The attribution is aligned left 58 * - The percent text is scaled based on the number of characters (1,2, or 3) in the string 59 */ 60 @Suppress("RtlHardcoded") 61 class BatteryLayersDrawable( 62 private val frameBg: Drawable, 63 private val frame: Drawable, 64 private val fill: BatteryFillDrawable, 65 private val textOnly: BatteryPercentTextOnlyDrawable, 66 private val spaceSharingText: BatterySpaceSharingPercentTextDrawable, 67 private val attribution: BatteryAttributionDrawable, 68 batteryState: BatteryDrawableState, 69 ) : LayerDrawable(arrayOf(frameBg, frame, fill, textOnly, spaceSharingText, attribution)) { 70 <lambda>null71 private val scaleMatrix = Matrix().also { it.setScale(1f, 1f) } 72 73 private val attrFullCanvas = RectF() 74 private val attrRightCanvas = RectF() 75 private val scaledAttrFullCanvas = RectF() 76 private val scaledAttrRightCanvas = RectF() 77 78 var batteryState = batteryState 79 set(value) { 80 if (field != value) { 81 // Update before we set the backing field so we can diff 82 handleUpdateState(field, value) 83 field = value 84 invalidateSelf() 85 } 86 } 87 88 var colors: BatteryColors = BatteryColors.LightThemeColors 89 set(value) { 90 field = value 91 updateColorProfile(batteryState.hasForegroundContent(), batteryState.color, value) 92 } 93 94 init { 95 isAutoMirrored = true 96 // Initialize the canvas rects since they are not static 97 setAttrRects(layoutDirection == View.LAYOUT_DIRECTION_RTL) 98 } 99 handleUpdateStatenull100 private fun handleUpdateState(old: BatteryDrawableState, new: BatteryDrawableState) { 101 if (new.level != old.level) { 102 fill.batteryLevel = new.level 103 textOnly.batteryLevel = new.level 104 spaceSharingText.batteryLevel = new.level 105 } 106 107 val shouldUpdateColors = 108 new.color != old.color || 109 new.attribution != attribution.drawable || 110 new.hasForegroundContent() != old.hasForegroundContent() 111 112 if (new.attribution != null && new.attribution != attribution.drawable) { 113 attribution.drawable = new.attribution 114 } 115 116 if (new.hasForegroundContent() != old.hasForegroundContent()) { 117 setFillInsets(new.hasForegroundContent()) 118 } 119 120 // Finally, update colors last if any of the above conditions were met, so that everything 121 // is properly tinted 122 if (shouldUpdateColors) { 123 updateColorProfile(new.hasForegroundContent(), new.color, colors) 124 } 125 } 126 updateColorProfilenull127 private fun updateColorProfile( 128 hasFg: Boolean, 129 color: ColorProfile, 130 colorInfo: BatteryColors, 131 ) { 132 frame.setTint(colorInfo.fg) 133 frameBg.setTint(colorInfo.bg) 134 textOnly.setTint(colorInfo.fg) 135 spaceSharingText.setTint(colorInfo.fg) 136 attribution.setTint(colorInfo.fg) 137 138 when (color) { 139 ColorProfile.None -> { 140 fill.fillColor = if (hasFg) colorInfo.fill else colorInfo.fillOnly 141 } 142 ColorProfile.Active -> { 143 fill.fillColor = colorInfo.activeFill 144 } 145 ColorProfile.Warning -> { 146 fill.fillColor = colorInfo.warnFill 147 } 148 ColorProfile.Error -> { 149 fill.fillColor = colorInfo.errorFill 150 } 151 } 152 } 153 setFillInsetsnull154 private fun setFillInsets( 155 hasFg: Boolean, 156 ) { 157 // Extra padding around the fill if there is nothing in the foreground 158 fill.fillInsetAmount = if (hasFg) 0f else 1.5f 159 } 160 onBoundsChangenull161 override fun onBoundsChange(bounds: Rect) { 162 super.onBoundsChange(bounds) 163 164 scaleMatrix.setScale( 165 bounds.width() / Metrics.ViewportWidth, 166 bounds.height() / Metrics.ViewportHeight 167 ) 168 169 scaleAttributionBounds() 170 } 171 onLayoutDirectionChangednull172 override fun onLayoutDirectionChanged(layoutDirection: Int): Boolean { 173 setAttrRects(layoutDirection == View.LAYOUT_DIRECTION_RTL) 174 scaleAttributionBounds() 175 176 return super.onLayoutDirectionChanged(layoutDirection) 177 } 178 setAttrRectsnull179 private fun setAttrRects(rtl: Boolean) { 180 // Local refs make the math easier to parse 181 val full = Metrics.AttrFullCanvasInsets 182 val side = Metrics.AttrRightCanvasInsets 183 val sideRtl = Metrics.AttrRightCanvasInsetsRtl 184 val vh = Metrics.ViewportHeight 185 val vw = Metrics.ViewportWidth 186 187 attrFullCanvas.set( 188 if (rtl) full.right else full.left, 189 full.top, 190 vw - if (rtl) full.left else full.right, 191 vh - full.bottom, 192 ) 193 attrRightCanvas.set( 194 if (rtl) sideRtl.left else side.left, 195 side.top, 196 vw - (if (rtl) sideRtl.right else side.right), 197 vh - side.bottom, 198 ) 199 } 200 201 /** If bounds (i.e., scale), or RTL properties change, we have to recalculate the attr bounds */ scaleAttributionBoundsnull202 private fun scaleAttributionBounds() { 203 scaleMatrix.mapRect(scaledAttrFullCanvas, attrFullCanvas) 204 scaleMatrix.mapRect(scaledAttrRightCanvas, attrRightCanvas) 205 } 206 drawnull207 override fun draw(canvas: Canvas) { 208 // 1. Draw the frame bg 209 frameBg.draw(canvas) 210 // 2. Then the frame itself 211 frame.draw(canvas) 212 213 // 3. Fill it the appropriate amount 214 fill.draw(canvas) 215 216 // 4. Decide what goes inside 217 if (batteryState.showPercent && batteryState.attribution != null) { 218 // 4a. percent & attribution. Implies space-sharing 219 220 // Configure the attribute to draw in a smaller bounding box and align left and use 221 // floor/ceil math to make sure we get every available pixel 222 attribution.gravity = Gravity.LEFT 223 attribution.setBounds( 224 floor(scaledAttrRightCanvas.left).toInt(), 225 floor(scaledAttrRightCanvas.top).toInt(), 226 ceil(scaledAttrRightCanvas.right).toInt(), 227 ceil(scaledAttrRightCanvas.bottom).toInt(), 228 ) 229 attribution.draw(canvas) 230 231 spaceSharingText.draw(canvas) 232 } else if (batteryState.showPercent) { 233 // 4b. Percent only 234 textOnly.draw(canvas) 235 } else if (batteryState.attribution != null) { 236 // 4c. Attribution only 237 attribution.gravity = Gravity.CENTER 238 attribution.setBounds( 239 scaledAttrFullCanvas.left.roundToInt(), 240 scaledAttrFullCanvas.top.roundToInt(), 241 scaledAttrFullCanvas.right.roundToInt(), 242 scaledAttrFullCanvas.bottom.roundToInt(), 243 ) 244 attribution.draw(canvas) 245 } 246 } 247 248 /** 249 * This drawable relies on [BatteryColors] to encode all alpha in their values, so we ignore 250 * externally-set alpha 251 */ setAlphanull252 override fun setAlpha(alpha: Int) {} 253 254 /** 255 * Interface that describes relevant top-level metrics for the proper rendering of this icon. 256 * The overall canvas is defined as ViewportWidth x ViewportHeight, which is hard coded to 24x14 257 * points. 258 * 259 * The attr canvas insets are rect inset definitions. That is, they are defined as l,t,r,b 260 * points from the nearest edge. Note that for RTL, we don't actually flip the text since 261 * numbers do not reverse for RTL locales. 262 */ 263 interface M { 264 val ViewportWidth: Float 265 val ViewportHeight: Float 266 267 /** 268 * Insets, oriented in the above viewport in LTR, that define the full canvas for a single 269 * foreground element. The element will be fit-center and center-aligned on this canvas 270 * 271 * 18x8 point size 272 */ 273 val AttrFullCanvasInsets: RectF 274 275 /** 276 * Insets, oriented in the above viewport in LTR, that define the partial canvas for a 277 * foreground element that shares space with the percent text. The element will be 278 * fit-center and left-aligned on this canvas. 279 * 280 * 6x6 point size 281 */ 282 val AttrRightCanvasInsets: RectF 283 284 /** 285 * Insets, oriented in the above viewport in RTL, that define the partial canvas for a 286 * foreground element that shares space with the percent text. The element will be 287 * fit-center and left-aligned on this canvas. 288 * 289 * 6x6 point size 290 */ 291 val AttrRightCanvasInsetsRtl: RectF 292 } 293 294 companion object { 295 private val PercentFont = Typeface.create("google-sans", Typeface.BOLD) 296 297 /** 298 * Think of this like the `android:<attr>` values in a drawable.xml file. [Metrics] defines 299 * relevant canvas and size information for us to layout this cluster of drawables 300 */ 301 val Metrics = 302 object : M { 303 override val ViewportWidth: Float = 24f 304 override val ViewportHeight: Float = 14f 305 306 override val AttrFullCanvasInsets = RectF(4f, 3f, 2f, 3f) 307 override val AttrRightCanvasInsets = RectF(16f, 4f, 2f, 4f) 308 override val AttrRightCanvasInsetsRtl = RectF(14f, 4f, 4f, 4f) 309 } 310 311 /** 312 * Create all of the layers needed by [BatteryLayersDrawable]. This class relies on the 313 * following resources to exist in order to properly render: 314 * - R.drawable.battery_unified_frame_bg 315 * - R.drawable.battery_unified_frame 316 * - R.string.battery_unified_frame_path_string 317 * - GoogleSans bold font 318 * 319 * See [BatteryDrawableState] for how to set the properties of the resulting class 320 */ 321 @Suppress("UseCompatLoadingForDrawables") newBatteryDrawablenull322 fun newBatteryDrawable( 323 context: Context, 324 initialState: BatteryDrawableState = BatteryDrawableState.DefaultInitialState, 325 ): BatteryLayersDrawable { 326 val framePath = 327 PathParser.createPathFromPathData( 328 context.getString(R.string.battery_unified_frame_path_string) 329 ) 330 331 val frameBg = 332 context.getDrawable(R.drawable.battery_unified_frame_bg) 333 ?: throw IllegalStateException("Missing battery_unified_frame_bg.xml") 334 val frame = 335 context.getDrawable(R.drawable.battery_unified_frame) 336 ?: throw IllegalStateException("Missing battery_unified_frame.xml") 337 val fill = BatteryFillDrawable(framePath) 338 val textOnly = BatteryPercentTextOnlyDrawable(PercentFont) 339 val spaceSharingText = BatterySpaceSharingPercentTextDrawable(PercentFont) 340 val attribution = BatteryAttributionDrawable(null) 341 342 return BatteryLayersDrawable( 343 frameBg = frameBg, 344 frame = frame, 345 fill = fill, 346 textOnly = textOnly, 347 spaceSharingText = spaceSharingText, 348 attribution = attribution, 349 batteryState = initialState, 350 ) 351 } 352 } 353 } 354