Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 0f52381b authored by Jorge Gil's avatar Jorge Gil
Browse files

Update App Header background/foreground style

Adds support for additional background and foreground color changes
based on system theme, app theme, focused state and default vs custom
headers. There are 16 different style variations to apply, so some
additional changes were made to make this possible and more readable.
Changes include:
1) Default header background drawable is now composed of two layers:
  backLayer - may be solid white, back or null. Black/white is needed
  when the frontLayer is semi transparent, to avoid showing app content
  behind it.
  fronLayer - usually a solid color (material token) with full opacity,
  but may have some transparency to differentiate between focused and
  unfocused windows that are all forced to use the inverse_surface color
  token, which happens when the system and app's light/dark theme do not
  match (e.g. dark app on light system theme)
2) New Header data class that holds the TaskInfo/System properties that
   have an effect on which style is applied.
3) New HeaderStyle data class, to represent the resulting
   background/foreground configuration calculated based on the Header
   and that will be applied to the view hierarchy.
4) Replaces the Maximize button's default background ripple with a
   custom RippleDrawable, to allow defining selector states with
   different color/alpha combinations.

Flag: com.android.window.flags.enable_themed_app_headers
Bug: 328668781
Bug: 336555032
Test: try all combinations of app states, verify styles
Change-Id: I5f08e66ecccb5909a6a714937af1370021a934fe
parent cf26cf9b
Loading
Loading
Loading
Loading
+28 −0
Original line number Diff line number Diff line
<?xml version="1.0" encoding="utf-8"?><!--
  ~ Copyright (C) 2024 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.
  -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:id="@+id/backLayer">
        <shape android:shape="rectangle">
            <solid android:color="#000000" />
        </shape>
    </item>

    <item android:id="@+id/frontLayer">
        <shape android:shape="rectangle">
            <solid android:color="#000000" />
        </shape>
    </item>
</layer-list>
 No newline at end of file
+1 −1
Original line number Diff line number Diff line
@@ -19,6 +19,7 @@
    xmlns:androidprv="http://schemas.android.com/apk/prv/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/desktop_mode_caption"
    android:background="@drawable/desktop_mode_header_background"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:gravity="center_horizontal"
@@ -96,7 +97,6 @@
        android:paddingHorizontal="10dp"
        android:paddingVertical="8dp"
        android:layout_marginEnd="8dp"
        android:tint="?androidprv:attr/materialColorOnSurface"
        android:background="?android:selectableItemBackgroundBorderless"
        android:contentDescription="@string/close_button_text"
        android:src="@drawable/desktop_mode_header_ic_close"
+0 −2
Original line number Diff line number Diff line
@@ -31,8 +31,6 @@
        android:layout_height="34dp"
        android:padding="5dp"
        android:contentDescription="@string/maximize_button_text"
        android:tint="?androidprv:attr/materialColorOnSurface"
        android:background="?android:selectableItemBackgroundBorderless"
        android:src="@drawable/decor_desktop_mode_maximize_button_dark"
        android:scaleType="fitCenter" />
</merge>
 No newline at end of file
+64 −7
Original line number Diff line number Diff line
@@ -20,15 +20,19 @@ import android.animation.ObjectAnimator
import android.animation.ValueAnimator
import android.content.Context
import android.content.res.ColorStateList
import android.graphics.Color
import android.graphics.drawable.RippleDrawable
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.ProgressBar
import androidx.annotation.ColorInt
import androidx.core.animation.doOnEnd
import androidx.core.animation.doOnStart
import androidx.core.content.ContextCompat
import com.android.window.flags.Flags
import com.android.wm.shell.R

private const val OPEN_MAXIMIZE_MENU_DELAY_ON_HOVER_MS = 350
@@ -90,7 +94,44 @@ class MaximizeButtonView(
        progressBar.visibility = View.INVISIBLE
    }

    fun setAnimationTints(darkMode: Boolean) {
    /**
     * Set the color tints of the maximize button views.
     *
     * @param darkMode whether the app's theme is in dark mode.
     * @param iconForegroundColor the color tint to use for the maximize icon to match the rest of
     *   the App Header icons
     * @param baseForegroundColor the base foreground color tint used by the App Header, used to style
     *   views within this button using the same base color but with different opacities.
     */
    fun setAnimationTints(
        darkMode: Boolean,
        iconForegroundColor: ColorStateList? = null,
        baseForegroundColor: Int? = null
    ) {
        if (Flags.enableThemedAppHeaders()) {
            requireNotNull(iconForegroundColor) { "Icon foreground color must be non-null" }
            requireNotNull(baseForegroundColor) { "Base foreground color must be non-null" }
            maximizeWindow.imageTintList = iconForegroundColor
            maximizeWindow.background = RippleDrawable(
                ColorStateList(
                    arrayOf(
                        intArrayOf(android.R.attr.state_hovered),
                        intArrayOf(android.R.attr.state_pressed),
                        intArrayOf(),
                    ),
                    intArrayOf(
                        replaceColorAlpha(baseForegroundColor, OPACITY_8),
                        replaceColorAlpha(baseForegroundColor, OPACITY_12),
                        Color.TRANSPARENT
                    )
                ),
                null,
                null
            )
            progressBar.progressTintList = ColorStateList.valueOf(baseForegroundColor)
                .withAlpha(OPACITY_12)
            progressBar.progressBackgroundTintList = ColorStateList.valueOf(Color.TRANSPARENT)
        } else {
            if (darkMode) {
                progressBar.progressTintList = ColorStateList.valueOf(
                    resources.getColor(R.color.desktop_mode_maximize_menu_progress_dark))
@@ -104,3 +145,19 @@ class MaximizeButtonView(
            }
        }
    }

    @ColorInt
    private fun replaceColorAlpha(@ColorInt color: Int, alpha: Int): Int {
        return Color.argb(
            alpha,
            Color.red(color),
            Color.green(color),
            Color.blue(color)
        )
    }

    companion object {
        private const val OPACITY_8 = 20
        private const val OPACITY_12 = 31
    }
}
+327 −0
Original line number Diff line number Diff line
@@ -4,8 +4,11 @@ import android.annotation.ColorInt
import android.app.ActivityManager.RunningTaskInfo
import android.content.res.ColorStateList
import android.content.res.Configuration
import android.content.res.Configuration.UI_MODE_NIGHT_MASK
import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.graphics.drawable.LayerDrawable
import android.view.View
import android.view.View.OnLongClickListener
import android.widget.ImageButton
@@ -15,10 +18,13 @@ import androidx.core.content.withStyledAttributes
import androidx.core.view.isVisible
import com.android.internal.R.attr.materialColorOnSecondaryContainer
import com.android.internal.R.attr.materialColorOnSurface
import com.android.internal.R.attr.materialColorOnSurfaceInverse
import com.android.internal.R.attr.materialColorSecondaryContainer
import com.android.internal.R.attr.materialColorSurfaceContainerHigh
import com.android.internal.R.attr.materialColorSurfaceContainerLow
import com.android.internal.R.attr.materialColorSurfaceDim
import com.android.internal.R.attr.materialColorSurfaceInverse
import com.android.window.flags.Flags
import com.android.wm.shell.R
import com.android.wm.shell.windowdecor.MaximizeButtonView
import com.android.wm.shell.windowdecor.extension.isLightCaptionBarAppearance
@@ -71,6 +77,14 @@ internal class DesktopModeAppControlsWindowDecorationViewHolder(
    }

    override fun bindData(taskInfo: RunningTaskInfo) {
        if (Flags.enableThemedAppHeaders()) {
            bindDataWithThemedHeaders(taskInfo)
        } else {
            bindDataLegacy(taskInfo)
        }
    }

    private fun bindDataLegacy(taskInfo: RunningTaskInfo) {
        captionView.setBackgroundColor(getCaptionBackgroundColor(taskInfo))
        val color = getAppNameAndButtonColor(taskInfo)
        val alpha = Color.alpha(color)
@@ -87,6 +101,45 @@ internal class DesktopModeAppControlsWindowDecorationViewHolder(
        maximizeButtonView.setAnimationTints(isDarkMode())
    }

    private fun bindDataWithThemedHeaders(taskInfo: RunningTaskInfo) {
        val header = fillHeaderInfo(taskInfo)
        val headerStyle = getHeaderStyle(header)

        // Caption Background
        val headerBackground = captionView.background as LayerDrawable
        val backLayer = headerBackground.findDrawableByLayerId(R.id.backLayer) as GradientDrawable
        val frontLayer = headerBackground.findDrawableByLayerId(R.id.frontLayer) as GradientDrawable
        when (headerStyle.background) {
            is HeaderStyle.Background.Opaque -> {
                backLayer.setColor(headerStyle.background.backLayerColor ?: Color.BLACK)
                frontLayer.setColor(headerStyle.background.frontLayerColor)
                frontLayer.alpha = headerStyle.background.frontLayerOpacity
            }
            HeaderStyle.Background.Transparent -> {
                backLayer.setColor(Color.TRANSPARENT)
                frontLayer.setColor(Color.TRANSPARENT)
                frontLayer.alpha = OPACITY_100
            }
        }

        // Caption Foreground
        val foregroundColor = headerStyle.foreground.color
        val foregroundAlpha = headerStyle.foreground.opacity
        val colorStateList = ColorStateList.valueOf(foregroundColor).withAlpha(foregroundAlpha)
        closeWindowButton.imageTintList = colorStateList
        expandMenuButton.imageTintList = colorStateList
        with (appNameTextView) {
            isVisible = header.type == Header.Type.DEFAULT
            setTextColor(colorStateList)
        }
        appIconImageView.imageAlpha = foregroundAlpha
        maximizeButtonView.setAnimationTints(
            darkMode = header.appTheme == Header.Theme.DARK,
            iconForegroundColor = colorStateList,
            baseForegroundColor = foregroundColor
        )
    }

    override fun onHandleMenuOpened() {}

    override fun onHandleMenuClosed() {}
@@ -107,6 +160,273 @@ internal class DesktopModeAppControlsWindowDecorationViewHolder(
        maximizeButtonView.startHoverAnimation()
    }

    private fun getHeaderStyle(header: Header): HeaderStyle {
        return HeaderStyle(
            background = getHeaderBackground(header),
            foreground = getHeaderForeground(header)
        )
    }

    private fun getHeaderBackground(
        header: Header
    ): HeaderStyle.Background {
        when (header.type) {
            Header.Type.DEFAULT -> {
                if (header.systemTheme.isLight() && header.appTheme.isLight() && header.isFocused) {
                    return HeaderStyle.Background.Opaque(
                        frontLayerColor = attrToColor(materialColorSecondaryContainer),
                        frontLayerOpacity = OPACITY_100,
                        backLayerColor = null
                    )
                }
                if (header.systemTheme.isLight() && header.appTheme.isLight() &&
                    !header.isFocused) {
                    return HeaderStyle.Background.Opaque(
                        frontLayerColor = attrToColor(materialColorSurfaceContainerLow),
                        frontLayerOpacity = OPACITY_100,
                        backLayerColor = null
                    )
                }
                if (header.systemTheme.isDark() && header.appTheme.isDark() && header.isFocused) {
                    return HeaderStyle.Background.Opaque(
                        frontLayerColor = attrToColor(materialColorSurfaceContainerHigh),
                        frontLayerOpacity = OPACITY_100,
                        backLayerColor = null
                    )
                }
                if (header.systemTheme.isDark() && header.appTheme.isDark() && !header.isFocused) {
                    return HeaderStyle.Background.Opaque(
                        frontLayerColor = attrToColor(materialColorSurfaceDim),
                        frontLayerOpacity = OPACITY_100,
                        backLayerColor = null
                    )
                }
                if (header.systemTheme.isLight() && header.appTheme.isDark() && header.isFocused) {
                    return HeaderStyle.Background.Opaque(
                        frontLayerColor = attrToColor(materialColorSurfaceInverse),
                        frontLayerOpacity = OPACITY_100,
                        backLayerColor = null
                    )
                }
                if (header.systemTheme.isLight() && header.appTheme.isDark() && !header.isFocused) {
                    return HeaderStyle.Background.Opaque(
                        frontLayerColor = attrToColor(materialColorSurfaceInverse),
                        frontLayerOpacity = OPACITY_30,
                        backLayerColor = Color.BLACK
                    )
                }
                if (header.systemTheme.isDark() && header.appTheme.isLight() && header.isFocused) {
                    return HeaderStyle.Background.Opaque(
                        frontLayerColor = attrToColor(materialColorSurfaceInverse),
                        frontLayerOpacity = OPACITY_100,
                        backLayerColor = null
                    )
                }
                if (header.systemTheme.isDark() && header.appTheme.isLight() && !header.isFocused) {
                    return HeaderStyle.Background.Opaque(
                        frontLayerColor = attrToColor(materialColorSurfaceInverse),
                        frontLayerOpacity = OPACITY_55,
                        backLayerColor = Color.WHITE
                    )
                }
                error("No other combination expected header=$header")
            }
            Header.Type.CUSTOM -> return HeaderStyle.Background.Transparent
        }
    }

    private fun getHeaderForeground(header: Header): HeaderStyle.Foreground {
        when (header.type) {
            Header.Type.DEFAULT -> {
                if (header.systemTheme.isLight() && header.appTheme.isLight() && header.isFocused) {
                    return HeaderStyle.Foreground(
                        color = attrToColor(materialColorOnSecondaryContainer),
                        opacity = OPACITY_100
                    )
                }
                if (header.systemTheme.isLight() && header.appTheme.isLight() &&
                    !header.isFocused) {
                    return HeaderStyle.Foreground(
                        color = attrToColor(materialColorOnSecondaryContainer),
                        opacity = OPACITY_65
                    )
                }
                if (header.systemTheme.isDark() && header.appTheme.isDark() && header.isFocused) {
                    return HeaderStyle.Foreground(
                        color = attrToColor(materialColorOnSurface),
                        opacity = OPACITY_100
                    )
                }
                if (header.systemTheme.isDark() && header.appTheme.isDark() && !header.isFocused) {
                    return HeaderStyle.Foreground(
                        color = attrToColor(materialColorOnSurface),
                        opacity = OPACITY_55
                    )
                }
                if (header.systemTheme.isLight() && header.appTheme.isDark() && header.isFocused) {
                    return HeaderStyle.Foreground(
                        color = attrToColor(materialColorOnSurfaceInverse),
                        opacity = OPACITY_100
                    )
                }
                if (header.systemTheme.isLight() && header.appTheme.isDark() && !header.isFocused) {
                    return HeaderStyle.Foreground(
                        color = attrToColor(materialColorOnSurfaceInverse),
                        opacity = OPACITY_65
                    )
                }
                if (header.systemTheme.isDark() && header.appTheme.isLight() && header.isFocused) {
                    return HeaderStyle.Foreground(
                        color = attrToColor(materialColorOnSurfaceInverse),
                        opacity = OPACITY_100
                    )
                }
                if (header.systemTheme.isDark() && header.appTheme.isLight() && !header.isFocused) {
                    return HeaderStyle.Foreground(
                        color = attrToColor(materialColorOnSurfaceInverse),
                        opacity = OPACITY_70
                    )
                }
                error("No other combination expected header=$header")
            }
            Header.Type.CUSTOM -> {
                if (header.systemTheme.isLight() && header.isAppearanceCaptionLight &&
                    header.isFocused) {
                    return HeaderStyle.Foreground(
                        color = attrToColor(materialColorOnSecondaryContainer),
                        opacity = OPACITY_100
                    )
                }
                if (header.systemTheme.isLight() && header.isAppearanceCaptionLight &&
                    !header.isFocused) {
                    return HeaderStyle.Foreground(
                        color = attrToColor(materialColorOnSecondaryContainer),
                        opacity = OPACITY_65
                    )
                }
                if (header.systemTheme.isDark() && !header.isAppearanceCaptionLight &&
                    header.isFocused) {
                    return HeaderStyle.Foreground(
                        color = attrToColor(materialColorOnSurface),
                        opacity = OPACITY_100
                    )
                }
                if (header.systemTheme.isDark() && !header.isAppearanceCaptionLight &&
                    !header.isFocused) {
                    return HeaderStyle.Foreground(
                        color = attrToColor(materialColorOnSurface),
                        opacity = OPACITY_55
                    )
                }
                if (header.systemTheme.isLight() && !header.isAppearanceCaptionLight &&
                    header.isFocused) {
                    return HeaderStyle.Foreground(
                        color = attrToColor(materialColorOnSurfaceInverse),
                        opacity = OPACITY_100
                    )
                }
                if (header.systemTheme.isLight() && !header.isAppearanceCaptionLight &&
                    !header.isFocused) {
                    return HeaderStyle.Foreground(
                        color = attrToColor(materialColorOnSurfaceInverse),
                        opacity = OPACITY_65
                    )
                }
                if (header.systemTheme.isDark() && header.isAppearanceCaptionLight &&
                    header.isFocused) {
                    return HeaderStyle.Foreground(
                        color = attrToColor(materialColorOnSurfaceInverse),
                        opacity = OPACITY_100
                    )
                }
                if (header.systemTheme.isDark() && header.isAppearanceCaptionLight &&
                    !header.isFocused) {
                    return HeaderStyle.Foreground(
                        color = attrToColor(materialColorOnSurfaceInverse),
                        opacity = OPACITY_70
                    )
                }
                error("No other combination expected header=$header")
            }
        }
    }

    private fun fillHeaderInfo(taskInfo: RunningTaskInfo): Header {
        return Header(
            type = if (taskInfo.isTransparentCaptionBarAppearance) {
                Header.Type.CUSTOM
            } else {
                Header.Type.DEFAULT
            },
            systemTheme = getSystemTheme(),
            appTheme = getAppTheme(taskInfo),
            isFocused = taskInfo.isFocused,
            isAppearanceCaptionLight = taskInfo.isLightCaptionBarAppearance
        )
    }

    private fun getSystemTheme(): Header.Theme {
        return if ((context.resources.configuration.uiMode and UI_MODE_NIGHT_MASK) ==
            Configuration.UI_MODE_NIGHT_YES) {
            Header.Theme.DARK
        } else {
            Header.Theme.LIGHT
        }
    }

    private fun getAppTheme(taskInfo: RunningTaskInfo): Header.Theme {
        // TODO: use app's uiMode to find its actual light/dark value. It needs to be added to the
        //   TaskInfo/TaskDescription.
        val backgroundColor = taskInfo.taskDescription?.backgroundColor ?: return getSystemTheme()
        return if (Color.valueOf(backgroundColor).luminance() < 0.5) {
            Header.Theme.DARK
        } else {
            Header.Theme.LIGHT
        }
    }

    @ColorInt
    private fun attrToColor(attr: Int): Int {
        context.withStyledAttributes(null, intArrayOf(attr), 0, 0) {
            return getColor(0, 0)
        }
        return Color.BLACK
    }

    data class Header(
        val type: Type,
        val systemTheme: Theme,
        val appTheme: Theme,
        val isFocused: Boolean,
        val isAppearanceCaptionLight: Boolean,
    ) {
        enum class Type { DEFAULT, CUSTOM }
        enum class Theme { LIGHT, DARK }
    }

    private fun Header.Theme.isLight(): Boolean = this == Header.Theme.LIGHT

    private fun Header.Theme.isDark(): Boolean = this == Header.Theme.DARK

    data class HeaderStyle(
        val background: Background,
        val foreground: Foreground
    ) {
        data class Foreground(
            @ColorInt val color: Int,
            val opacity: Int
        )

        sealed class Background {
            data object Transparent : Background()
            data class Opaque(
                @ColorInt val frontLayerColor: Int,
                val frontLayerOpacity: Int,
                @ColorInt val backLayerColor: Int?
            ) : Background()
        }
    }

    @ColorInt
    private fun getCaptionBackgroundColor(taskInfo: RunningTaskInfo): Int {
        if (taskInfo.isTransparentCaptionBarAppearance) {
@@ -171,8 +491,15 @@ internal class DesktopModeAppControlsWindowDecorationViewHolder(

    companion object {
        private const val TAG = "DesktopModeAppControlsWindowDecorationViewHolder"

        private const val DARK_THEME_UNFOCUSED_OPACITY = 140 // 55%
        private const val LIGHT_THEME_UNFOCUSED_OPACITY = 166 // 65%
        private const val FOCUSED_OPACITY = 255

        private const val OPACITY_100 = 255
        private const val OPACITY_30 = 77
        private const val OPACITY_55 = 140
        private const val OPACITY_65 = 166
        private const val OPACITY_70 = 179
    }
}