Adding hover state to system icons in status bar and shade header
Adding StatusHoverListener which is added as onHoverListener to the status view that should be hoverable in keyguard status bar, regular status bar and shade header. Listener adds drawable overlay on top of the view on cursor hover. The color of the drawable is: - keyguard: managed manually from KeyboardStatusBarView (based on theme) - regular status bar: dependent on dark areas - shade header: always light Also adding StateFlow to SysuiDarkIconDispatcher so new consumers don't have to use regular listeners. Test: StatusHoverListenerTest Test: screenshot test coming soon Fixes: 283800652 Fixes: 249859409 Change-Id: Id9c63f5d125a662d98abe5c39f6e450a516e9823
This commit is contained in:
@@ -26,6 +26,7 @@ import com.android.systemui.plugins.annotations.DependsOn;
|
||||
import com.android.systemui.plugins.annotations.ProvidesInterface;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
|
||||
/**
|
||||
* Dispatches events to {@link DarkReceiver}s about changes in darkness, tint area and dark
|
||||
@@ -78,7 +79,7 @@ public interface DarkIconDispatcher {
|
||||
* @return the tint to apply to view depending on the desired tint color and
|
||||
* the screen tintArea in which to apply that tint
|
||||
*/
|
||||
static int getTint(ArrayList<Rect> tintAreas, View view, int color) {
|
||||
static int getTint(Collection<Rect> tintAreas, View view, int color) {
|
||||
if (isInAreas(tintAreas, view)) {
|
||||
return color;
|
||||
} else {
|
||||
@@ -90,7 +91,7 @@ public interface DarkIconDispatcher {
|
||||
* @return true if more than half of the view area are in any of the given
|
||||
* areas, false otherwise
|
||||
*/
|
||||
static boolean isInAreas(ArrayList<Rect> areas, View view) {
|
||||
static boolean isInAreas(Collection<Rect> areas, View view) {
|
||||
if (areas.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
<color name="qs_detail_button_white">#B3FFFFFF</color><!-- 70% white -->
|
||||
<color name="status_bar_clock_color">#FFFFFFFF</color>
|
||||
<color name="qs_tile_disabled_color">#9E9E9E</color> <!-- 38% black -->
|
||||
<color name="status_bar_icons_hover_color_light">#38FFFFFF</color> <!-- 22% white -->
|
||||
<color name="status_bar_icons_hover_color_dark">#38000000</color> <!-- 22% black -->
|
||||
|
||||
<!-- The color of the background in the separated list of the Global Actions menu -->
|
||||
<color name="global_actions_separated_background">#F5F5F5</color>
|
||||
|
||||
@@ -475,6 +475,8 @@
|
||||
<!-- Margin start of the system icons super container -->
|
||||
<dimen name="system_icons_super_container_margin_start">16dp</dimen>
|
||||
|
||||
<dimen name="status_icons_hover_state_background_radius">16dp</dimen>
|
||||
|
||||
<!-- Width for the notification panel and related windows -->
|
||||
<dimen name="match_parent">-1px</dimen>
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import com.android.systemui.plugins.statusbar.StatusBarStateController;
|
||||
import com.android.systemui.statusbar.StatusBarStateControllerImpl;
|
||||
import com.android.systemui.statusbar.phone.ActivityStarterImpl;
|
||||
import com.android.systemui.statusbar.phone.DarkIconDispatcherImpl;
|
||||
import com.android.systemui.statusbar.phone.SysuiDarkIconDispatcher;
|
||||
import com.android.systemui.volume.VolumeDialogControllerImpl;
|
||||
|
||||
import dagger.Binds;
|
||||
@@ -49,6 +50,10 @@ public abstract class PluginModule {
|
||||
@Binds
|
||||
abstract DarkIconDispatcher provideDarkIconDispatcher(DarkIconDispatcherImpl controllerImpl);
|
||||
|
||||
@Binds
|
||||
abstract SysuiDarkIconDispatcher provideSysuiDarkIconDispatcher(
|
||||
DarkIconDispatcherImpl controllerImpl);
|
||||
|
||||
/** */
|
||||
@Binds
|
||||
abstract FalsingManager provideFalsingManager(FalsingManagerProxy falsingManagerImpl);
|
||||
|
||||
@@ -61,6 +61,7 @@ import com.android.systemui.statusbar.phone.StatusBarContentInsetsProvider
|
||||
import com.android.systemui.statusbar.phone.StatusBarIconController
|
||||
import com.android.systemui.statusbar.phone.StatusBarLocation
|
||||
import com.android.systemui.statusbar.phone.StatusIconContainer
|
||||
import com.android.systemui.statusbar.phone.StatusOverlayHoverListenerFactory
|
||||
import com.android.systemui.statusbar.policy.Clock
|
||||
import com.android.systemui.statusbar.policy.ConfigurationController
|
||||
import com.android.systemui.statusbar.policy.NextAlarmController
|
||||
@@ -99,6 +100,7 @@ constructor(
|
||||
private val qsBatteryModeController: QsBatteryModeController,
|
||||
private val nextAlarmController: NextAlarmController,
|
||||
private val activityStarter: ActivityStarter,
|
||||
private val statusOverlayHoverListenerFactory: StatusOverlayHoverListenerFactory,
|
||||
) : ViewController<View>(header), Dumpable {
|
||||
|
||||
companion object {
|
||||
@@ -326,6 +328,9 @@ constructor(
|
||||
demoModeController.addCallback(demoModeReceiver)
|
||||
statusBarIconController.addIconGroup(iconManager)
|
||||
nextAlarmController.addCallback(nextAlarmCallback)
|
||||
systemIcons.setOnHoverListener(
|
||||
statusOverlayHoverListenerFactory.createListener(systemIcons)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onViewDetached() {
|
||||
@@ -336,6 +341,7 @@ constructor(
|
||||
demoModeController.removeCallback(demoModeReceiver)
|
||||
statusBarIconController.removeIconGroup(iconManager)
|
||||
nextAlarmController.removeCallback(nextAlarmCallback)
|
||||
systemIcons.setOnHoverListener(null)
|
||||
}
|
||||
|
||||
fun disable(state1: Int, state2: Int, animate: Boolean) {
|
||||
|
||||
@@ -32,6 +32,11 @@ import java.util.ArrayList;
|
||||
|
||||
import javax.inject.Inject;
|
||||
|
||||
import kotlinx.coroutines.flow.FlowKt;
|
||||
import kotlinx.coroutines.flow.MutableStateFlow;
|
||||
import kotlinx.coroutines.flow.StateFlow;
|
||||
import kotlinx.coroutines.flow.StateFlowKt;
|
||||
|
||||
/**
|
||||
*/
|
||||
@SysUISingleton
|
||||
@@ -47,6 +52,9 @@ public class DarkIconDispatcherImpl implements SysuiDarkIconDispatcher,
|
||||
private int mDarkModeIconColorSingleTone;
|
||||
private int mLightModeIconColorSingleTone;
|
||||
|
||||
private final MutableStateFlow<DarkChange> mDarkChangeFlow = StateFlowKt.MutableStateFlow(
|
||||
DarkChange.EMPTY);
|
||||
|
||||
/**
|
||||
*/
|
||||
@Inject
|
||||
@@ -66,6 +74,11 @@ public class DarkIconDispatcherImpl implements SysuiDarkIconDispatcher,
|
||||
return mTransitionsController;
|
||||
}
|
||||
|
||||
@Override
|
||||
public StateFlow<DarkChange> darkChangeFlow() {
|
||||
return FlowKt.asStateFlow(mDarkChangeFlow);
|
||||
}
|
||||
|
||||
public void addDarkReceiver(DarkReceiver receiver) {
|
||||
mReceivers.put(receiver, receiver);
|
||||
receiver.onDarkChanged(mTintAreas, mDarkIntensity, mIconTint);
|
||||
@@ -122,6 +135,7 @@ public class DarkIconDispatcherImpl implements SysuiDarkIconDispatcher,
|
||||
}
|
||||
|
||||
private void applyIconTint() {
|
||||
mDarkChangeFlow.setValue(new DarkChange(mTintAreas, mDarkIntensity, mIconTint));
|
||||
for (int i = 0; i < mReceivers.size(); i++) {
|
||||
mReceivers.valueAt(i).onDarkChanged(mTintAreas, mDarkIntensity, mIconTint);
|
||||
}
|
||||
|
||||
@@ -34,7 +34,6 @@ import android.view.DisplayCutout;
|
||||
import android.view.Gravity;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.ViewTreeObserver;
|
||||
import android.view.WindowInsets;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.LinearLayout;
|
||||
@@ -43,11 +42,11 @@ import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.VisibleForTesting;
|
||||
|
||||
import com.android.app.animation.Interpolators;
|
||||
import com.android.settingslib.Utils;
|
||||
import com.android.systemui.R;
|
||||
import com.android.systemui.battery.BatteryMeterView;
|
||||
import com.android.systemui.plugins.DarkIconDispatcher.DarkReceiver;
|
||||
import com.android.systemui.statusbar.phone.SysuiDarkIconDispatcher.DarkChange;
|
||||
import com.android.systemui.statusbar.phone.userswitcher.StatusBarUserSwitcherContainer;
|
||||
import com.android.systemui.user.ui.binder.StatusBarUserChipViewBinder;
|
||||
import com.android.systemui.user.ui.viewmodel.StatusBarUserChipViewModel;
|
||||
@@ -55,6 +54,11 @@ import com.android.systemui.user.ui.viewmodel.StatusBarUserChipViewModel;
|
||||
import java.io.PrintWriter;
|
||||
import java.util.ArrayList;
|
||||
|
||||
import kotlinx.coroutines.flow.FlowKt;
|
||||
import kotlinx.coroutines.flow.MutableStateFlow;
|
||||
import kotlinx.coroutines.flow.StateFlow;
|
||||
import kotlinx.coroutines.flow.StateFlowKt;
|
||||
|
||||
/**
|
||||
* The header group on Keyguard.
|
||||
*/
|
||||
@@ -83,6 +87,8 @@ public class KeyguardStatusBarView extends RelativeLayout {
|
||||
private int mStatusBarPaddingEnd;
|
||||
private int mMinDotWidth;
|
||||
private View mSystemIconsContainer;
|
||||
private final MutableStateFlow<DarkChange> mDarkChange = StateFlowKt.MutableStateFlow(
|
||||
DarkChange.EMPTY);
|
||||
|
||||
private View mCutoutSpace;
|
||||
private ViewGroup mStatusIconArea;
|
||||
@@ -374,49 +380,6 @@ public class KeyguardStatusBarView extends RelativeLayout {
|
||||
return mKeyguardUserAvatarEnabled;
|
||||
}
|
||||
|
||||
private void animateNextLayoutChange() {
|
||||
final int systemIconsCurrentX = mSystemIconsContainer.getLeft();
|
||||
final boolean userAvatarVisible = mMultiUserAvatar.getParent() == mStatusIconArea;
|
||||
getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() {
|
||||
@Override
|
||||
public boolean onPreDraw() {
|
||||
getViewTreeObserver().removeOnPreDrawListener(this);
|
||||
boolean userAvatarHiding = userAvatarVisible
|
||||
&& mMultiUserAvatar.getParent() != mStatusIconArea;
|
||||
mSystemIconsContainer.setX(systemIconsCurrentX);
|
||||
mSystemIconsContainer.animate()
|
||||
.translationX(0)
|
||||
.setDuration(400)
|
||||
.setStartDelay(userAvatarHiding ? 300 : 0)
|
||||
.setInterpolator(Interpolators.FAST_OUT_SLOW_IN)
|
||||
.start();
|
||||
if (userAvatarHiding) {
|
||||
getOverlay().add(mMultiUserAvatar);
|
||||
mMultiUserAvatar.animate()
|
||||
.alpha(0f)
|
||||
.setDuration(300)
|
||||
.setStartDelay(0)
|
||||
.setInterpolator(Interpolators.ALPHA_OUT)
|
||||
.withEndAction(() -> {
|
||||
mMultiUserAvatar.setAlpha(1f);
|
||||
getOverlay().remove(mMultiUserAvatar);
|
||||
})
|
||||
.start();
|
||||
|
||||
} else {
|
||||
mMultiUserAvatar.setAlpha(0f);
|
||||
mMultiUserAvatar.animate()
|
||||
.alpha(1f)
|
||||
.setDuration(300)
|
||||
.setStartDelay(200)
|
||||
.setInterpolator(Interpolators.ALPHA_IN);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setVisibility(int visibility) {
|
||||
super.setVisibility(visibility);
|
||||
@@ -474,6 +437,7 @@ public class KeyguardStatusBarView extends RelativeLayout {
|
||||
iconManager.setTint(iconColor);
|
||||
}
|
||||
|
||||
mDarkChange.setValue(new DarkChange(mEmptyTintRect, intensity, iconColor));
|
||||
applyDarkness(R.id.battery, mEmptyTintRect, intensity, iconColor);
|
||||
applyDarkness(R.id.clock, mEmptyTintRect, intensity, iconColor);
|
||||
}
|
||||
@@ -536,4 +500,8 @@ public class KeyguardStatusBarView extends RelativeLayout {
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
|
||||
Trace.endSection();
|
||||
}
|
||||
|
||||
public StateFlow<DarkChange> darkChangeFlow() {
|
||||
return FlowKt.asStateFlow(mDarkChange);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,8 +69,6 @@ import com.android.systemui.user.ui.viewmodel.StatusBarUserChipViewModel;
|
||||
import com.android.systemui.util.ViewController;
|
||||
import com.android.systemui.util.settings.SecureSettings;
|
||||
|
||||
import kotlin.Unit;
|
||||
|
||||
import java.io.PrintWriter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
@@ -78,6 +76,8 @@ import java.util.concurrent.Executor;
|
||||
|
||||
import javax.inject.Inject;
|
||||
|
||||
import kotlin.Unit;
|
||||
|
||||
/** View Controller for {@link com.android.systemui.statusbar.phone.KeyguardStatusBarView}. */
|
||||
public class KeyguardStatusBarViewController extends ViewController<KeyguardStatusBarView> {
|
||||
private static final String TAG = "KeyguardStatusBarViewController";
|
||||
@@ -119,6 +119,9 @@ public class KeyguardStatusBarViewController extends ViewController<KeyguardStat
|
||||
private final Object mLock = new Object();
|
||||
private final KeyguardLogger mLogger;
|
||||
|
||||
private View mSystemIconsContainer;
|
||||
private final StatusOverlayHoverListenerFactory mStatusOverlayHoverListenerFactory;
|
||||
|
||||
// TODO(b/273443374): remove
|
||||
private NotificationMediaManager mNotificationMediaManager;
|
||||
|
||||
@@ -286,7 +289,8 @@ public class KeyguardStatusBarViewController extends ViewController<KeyguardStat
|
||||
CommandQueue commandQueue,
|
||||
@Main Executor mainExecutor,
|
||||
KeyguardLogger logger,
|
||||
NotificationMediaManager notificationMediaManager
|
||||
NotificationMediaManager notificationMediaManager,
|
||||
StatusOverlayHoverListenerFactory statusOverlayHoverListenerFactory
|
||||
) {
|
||||
super(view);
|
||||
mCarrierTextController = carrierTextController;
|
||||
@@ -339,6 +343,7 @@ public class KeyguardStatusBarViewController extends ViewController<KeyguardStat
|
||||
this::updateViewState
|
||||
);
|
||||
mNotificationMediaManager = notificationMediaManager;
|
||||
mStatusOverlayHoverListenerFactory = statusOverlayHoverListenerFactory;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -363,6 +368,10 @@ public class KeyguardStatusBarViewController extends ViewController<KeyguardStat
|
||||
mTintedIconManager.setBlockList(getBlockedIcons());
|
||||
mStatusBarIconController.addIconGroup(mTintedIconManager);
|
||||
}
|
||||
mSystemIconsContainer = mView.findViewById(R.id.system_icons);
|
||||
StatusOverlayHoverListener hoverListener = mStatusOverlayHoverListenerFactory
|
||||
.createDarkAwareListener(mSystemIconsContainer, mView.darkChangeFlow());
|
||||
mSystemIconsContainer.setOnHoverListener(hoverListener);
|
||||
mView.setOnApplyWindowInsetsListener(
|
||||
(view, windowInsets) -> mView.updateWindowInsets(windowInsets, mInsetsProvider));
|
||||
mSecureSettings.registerContentObserverForUser(
|
||||
@@ -376,6 +385,7 @@ public class KeyguardStatusBarViewController extends ViewController<KeyguardStat
|
||||
|
||||
@Override
|
||||
protected void onViewDetached() {
|
||||
mSystemIconsContainer.setOnHoverListener(null);
|
||||
mConfigurationController.removeCallback(mConfigurationListener);
|
||||
mAnimationScheduler.removeCallback(mAnimationCallback);
|
||||
mUserInfoController.removeCallback(mOnUserInfoChangedListener);
|
||||
|
||||
@@ -63,9 +63,12 @@ class PhoneStatusBarViewController private constructor(
|
||||
private val userChipViewModel: StatusBarUserChipViewModel,
|
||||
private val viewUtil: ViewUtil,
|
||||
private val featureFlags: FeatureFlags,
|
||||
private val configurationController: ConfigurationController
|
||||
private val configurationController: ConfigurationController,
|
||||
private val statusOverlayHoverListenerFactory: StatusOverlayHoverListenerFactory,
|
||||
) : ViewController<PhoneStatusBarView>(view) {
|
||||
|
||||
private lateinit var statusContainer: View
|
||||
|
||||
private val configurationListener = object : ConfigurationController.ConfigurationListener {
|
||||
override fun onConfigChanged(newConfig: Configuration?) {
|
||||
mView.updateResources()
|
||||
@@ -73,6 +76,9 @@ class PhoneStatusBarViewController private constructor(
|
||||
}
|
||||
|
||||
override fun onViewAttached() {
|
||||
statusContainer = mView.findViewById(R.id.system_icons)
|
||||
statusContainer.setOnHoverListener(
|
||||
statusOverlayHoverListenerFactory.createDarkAwareListener(statusContainer))
|
||||
if (moveFromCenterAnimationController == null) return
|
||||
|
||||
val statusBarLeftSide: View = mView.findViewById(R.id.status_bar_start_side_except_heads_up)
|
||||
@@ -104,6 +110,7 @@ class PhoneStatusBarViewController private constructor(
|
||||
}
|
||||
|
||||
override fun onViewDetached() {
|
||||
statusContainer.setOnHoverListener(null)
|
||||
progressProvider?.setReadyToHandleTransition(false)
|
||||
moveFromCenterAnimationController?.onViewDetached()
|
||||
configurationController.removeCallback(configurationListener)
|
||||
@@ -245,6 +252,7 @@ class PhoneStatusBarViewController private constructor(
|
||||
private val shadeLogger: ShadeLogger,
|
||||
private val viewUtil: ViewUtil,
|
||||
private val configurationController: ConfigurationController,
|
||||
private val statusOverlayHoverListenerFactory: StatusOverlayHoverListenerFactory,
|
||||
) {
|
||||
fun create(
|
||||
view: PhoneStatusBarView
|
||||
@@ -268,7 +276,8 @@ class PhoneStatusBarViewController private constructor(
|
||||
userChipViewModel,
|
||||
viewUtil,
|
||||
featureFlags,
|
||||
configurationController
|
||||
configurationController,
|
||||
statusOverlayHoverListenerFactory,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* Copyright (C) 2023 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 com.android.systemui.statusbar.phone
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.content.res.Resources
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.PaintDrawable
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.View.OnHoverListener
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import com.android.systemui.R
|
||||
import com.android.systemui.dagger.qualifiers.Main
|
||||
import com.android.systemui.lifecycle.repeatWhenAttached
|
||||
import com.android.systemui.plugins.DarkIconDispatcher
|
||||
import com.android.systemui.statusbar.phone.SysuiDarkIconDispatcher.DarkChange
|
||||
import com.android.systemui.statusbar.policy.ConfigurationController
|
||||
import com.android.systemui.statusbar.policy.ConfigurationController.ConfigurationListener
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class StatusOverlayHoverListenerFactory
|
||||
@Inject
|
||||
constructor(
|
||||
@Main private val resources: Resources,
|
||||
private val configurationController: ConfigurationController,
|
||||
private val darkIconDispatcher: SysuiDarkIconDispatcher,
|
||||
) {
|
||||
|
||||
/** Creates listener always using the same light color for overlay */
|
||||
fun createListener(view: View) =
|
||||
StatusOverlayHoverListener(
|
||||
view,
|
||||
configurationController,
|
||||
resources,
|
||||
flowOf(HoverTheme.LIGHT),
|
||||
)
|
||||
|
||||
/**
|
||||
* Creates listener using [DarkIconDispatcher] to determine light or dark color of the overlay
|
||||
*/
|
||||
fun createDarkAwareListener(view: View) =
|
||||
createDarkAwareListener(view, darkIconDispatcher.darkChangeFlow())
|
||||
|
||||
/**
|
||||
* Creates listener using provided [DarkChange] producer to determine light or dark color of the
|
||||
* overlay
|
||||
*/
|
||||
fun createDarkAwareListener(view: View, darkFlow: StateFlow<DarkChange>) =
|
||||
StatusOverlayHoverListener(
|
||||
view,
|
||||
configurationController,
|
||||
resources,
|
||||
darkFlow.map { toHoverTheme(view, it) },
|
||||
)
|
||||
|
||||
private fun toHoverTheme(view: View, darkChange: DarkChange): HoverTheme {
|
||||
val calculatedTint = DarkIconDispatcher.getTint(darkChange.areas, view, darkChange.tint)
|
||||
// currently calculated tint is either white or some shade of black.
|
||||
// So checking for Color.WHITE is deterministic compared to checking for Color.BLACK.
|
||||
// In the future checking Color.luminance() might be more appropriate.
|
||||
return if (calculatedTint == Color.WHITE) HoverTheme.LIGHT else HoverTheme.DARK
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* theme of hover drawable - it's different from device theme. This theme depends on view's
|
||||
* background and/or dark value returned from [DarkIconDispatcher]
|
||||
*/
|
||||
enum class HoverTheme {
|
||||
LIGHT,
|
||||
DARK
|
||||
}
|
||||
|
||||
/**
|
||||
* [OnHoverListener] that adds [Drawable] overlay on top of the status icons when cursor/stylus
|
||||
* starts hovering over them and removes overlay when status icons are no longer hovered
|
||||
*/
|
||||
class StatusOverlayHoverListener(
|
||||
view: View,
|
||||
configurationController: ConfigurationController,
|
||||
private val resources: Resources,
|
||||
private val themeFlow: Flow<HoverTheme>,
|
||||
) : OnHoverListener {
|
||||
|
||||
@ColorInt private var darkColor: Int = 0
|
||||
@ColorInt private var lightColor: Int = 0
|
||||
private var cornerRadius = 0f
|
||||
|
||||
private var lastTheme = HoverTheme.LIGHT
|
||||
|
||||
val backgroundColor
|
||||
get() = if (lastTheme == HoverTheme.LIGHT) lightColor else darkColor
|
||||
|
||||
init {
|
||||
view.repeatWhenAttached {
|
||||
lifecycleScope.launch {
|
||||
val configurationListener =
|
||||
object : ConfigurationListener {
|
||||
override fun onConfigChanged(newConfig: Configuration?) {
|
||||
updateResources()
|
||||
}
|
||||
}
|
||||
repeatOnLifecycle(Lifecycle.State.CREATED) {
|
||||
configurationController.addCallback(configurationListener)
|
||||
}
|
||||
configurationController.removeCallback(configurationListener)
|
||||
}
|
||||
lifecycleScope.launch { themeFlow.collect { lastTheme = it } }
|
||||
}
|
||||
updateResources()
|
||||
}
|
||||
|
||||
override fun onHover(v: View, event: MotionEvent): Boolean {
|
||||
if (event.action == MotionEvent.ACTION_HOVER_ENTER) {
|
||||
val drawable =
|
||||
PaintDrawable(backgroundColor).apply {
|
||||
setCornerRadius(cornerRadius)
|
||||
setBounds(0, 0, v.width, v.height)
|
||||
}
|
||||
v.overlay.add(drawable)
|
||||
} else if (event.action == MotionEvent.ACTION_HOVER_EXIT) {
|
||||
v.overlay.clear()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun updateResources() {
|
||||
lightColor = resources.getColor(R.color.status_bar_icons_hover_color_light)
|
||||
darkColor = resources.getColor(R.color.status_bar_icons_hover_color_dark)
|
||||
cornerRadius = resources.getDimension(R.dimen.status_icons_hover_state_background_radius)
|
||||
}
|
||||
}
|
||||
@@ -16,9 +16,16 @@
|
||||
|
||||
package com.android.systemui.statusbar.phone;
|
||||
|
||||
import android.graphics.Rect;
|
||||
|
||||
import com.android.systemui.Dumpable;
|
||||
import com.android.systemui.plugins.DarkIconDispatcher;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
|
||||
import kotlinx.coroutines.flow.StateFlow;
|
||||
|
||||
/**
|
||||
* Dispatches events to {@link DarkReceiver}s about changes in darkness, tint area
|
||||
* and dark intensity.
|
||||
@@ -29,4 +36,26 @@ public interface SysuiDarkIconDispatcher extends DarkIconDispatcher, Dumpable {
|
||||
* @return LightBarTransitionsController
|
||||
*/
|
||||
LightBarTransitionsController getTransitionsController();
|
||||
|
||||
/**
|
||||
* Flow equivalent of registering {@link DarkReceiver} using
|
||||
* {@link DarkIconDispatcher#addDarkReceiver(DarkReceiver)}
|
||||
*/
|
||||
StateFlow<DarkChange> darkChangeFlow();
|
||||
|
||||
/** Model for {@link #darkChangeFlow()} */
|
||||
class DarkChange {
|
||||
|
||||
public static final DarkChange EMPTY = new DarkChange(new ArrayList<>(), 0, 0);
|
||||
|
||||
public DarkChange(Collection<Rect> areas, float darkIntensity, int tint) {
|
||||
this.areas = areas;
|
||||
this.darkIntensity = darkIntensity;
|
||||
this.tint = tint;
|
||||
}
|
||||
|
||||
public final Collection<Rect> areas;
|
||||
public final float darkIntensity;
|
||||
public final int tint;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ import com.android.systemui.shade.carrier.ShadeCarrierGroupController
|
||||
import com.android.systemui.statusbar.phone.StatusBarContentInsetsProvider
|
||||
import com.android.systemui.statusbar.phone.StatusBarIconController
|
||||
import com.android.systemui.statusbar.phone.StatusIconContainer
|
||||
import com.android.systemui.statusbar.phone.StatusOverlayHoverListenerFactory
|
||||
import com.android.systemui.statusbar.policy.Clock
|
||||
import com.android.systemui.statusbar.policy.FakeConfigurationController
|
||||
import com.android.systemui.statusbar.policy.NextAlarmController
|
||||
@@ -123,6 +124,7 @@ class ShadeHeaderControllerTest : SysuiTestCase() {
|
||||
@Mock private lateinit var qsBatteryModeController: QsBatteryModeController
|
||||
@Mock private lateinit var nextAlarmController: NextAlarmController
|
||||
@Mock private lateinit var activityStarter: ActivityStarter
|
||||
@Mock private lateinit var mStatusOverlayHoverListenerFactory: StatusOverlayHoverListenerFactory
|
||||
|
||||
@JvmField @Rule val mockitoRule = MockitoJUnit.rule()
|
||||
var viewVisibility = View.GONE
|
||||
@@ -194,6 +196,7 @@ class ShadeHeaderControllerTest : SysuiTestCase() {
|
||||
qsBatteryModeController,
|
||||
nextAlarmController,
|
||||
activityStarter,
|
||||
mStatusOverlayHoverListenerFactory
|
||||
)
|
||||
whenever(view.isAttachedToWindow).thenReturn(true)
|
||||
shadeHeaderController.init()
|
||||
|
||||
@@ -122,6 +122,7 @@ public class KeyguardStatusBarViewControllerTest extends SysuiTestCase {
|
||||
@Mock private KeyguardLogger mLogger;
|
||||
|
||||
@Mock private NotificationMediaManager mNotificationMediaManager;
|
||||
@Mock private StatusOverlayHoverListenerFactory mStatusOverlayHoverListenerFactory;
|
||||
|
||||
private TestShadeViewStateProvider mShadeViewStateProvider;
|
||||
private KeyguardStatusBarView mKeyguardStatusBarView;
|
||||
@@ -171,7 +172,8 @@ public class KeyguardStatusBarViewControllerTest extends SysuiTestCase {
|
||||
mCommandQueue,
|
||||
mFakeExecutor,
|
||||
mLogger,
|
||||
mNotificationMediaManager
|
||||
mNotificationMediaManager,
|
||||
mStatusOverlayHoverListenerFactory
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,8 @@ class PhoneStatusBarViewControllerTest : SysuiTestCase() {
|
||||
@Mock
|
||||
private lateinit var configurationController: ConfigurationController
|
||||
@Mock
|
||||
private lateinit var mStatusOverlayHoverListenerFactory: StatusOverlayHoverListenerFactory
|
||||
@Mock
|
||||
private lateinit var userChipViewModel: StatusBarUserChipViewModel
|
||||
@Mock
|
||||
private lateinit var centralSurfacesImpl: CentralSurfacesImpl
|
||||
@@ -204,7 +206,8 @@ class PhoneStatusBarViewControllerTest : SysuiTestCase() {
|
||||
sceneInteractor,
|
||||
shadeLogger,
|
||||
viewUtil,
|
||||
configurationController
|
||||
configurationController,
|
||||
mStatusOverlayHoverListenerFactory
|
||||
).create(view).also {
|
||||
it.init()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
/*
|
||||
* Copyright (C) 2023 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 com.android.systemui.statusbar.phone
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.graphics.drawable.PaintDrawable
|
||||
import android.os.SystemClock
|
||||
import android.testing.AndroidTestingRunner
|
||||
import android.testing.TestableLooper
|
||||
import android.testing.TestableLooper.RunWithLooper
|
||||
import android.testing.ViewUtils
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroupOverlay
|
||||
import android.widget.LinearLayout
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.test.filters.SmallTest
|
||||
import com.android.systemui.R
|
||||
import com.android.systemui.SysuiTestCase
|
||||
import com.android.systemui.statusbar.phone.SysuiDarkIconDispatcher.DarkChange
|
||||
import com.android.systemui.statusbar.policy.FakeConfigurationController
|
||||
import com.android.systemui.util.mockito.argumentCaptor
|
||||
import com.android.systemui.util.mockito.mock
|
||||
import com.android.systemui.util.mockito.whenever
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.mockito.Mockito.verify
|
||||
|
||||
@RunWith(AndroidTestingRunner::class)
|
||||
@RunWithLooper(setAsMainLooper = true)
|
||||
@SmallTest
|
||||
class StatusOverlayHoverListenerTest : SysuiTestCase() {
|
||||
|
||||
private val viewOverlay = mock<ViewGroupOverlay>()
|
||||
private val overlayCaptor = argumentCaptor<Drawable>()
|
||||
private val darkDispatcher = mock<SysuiDarkIconDispatcher>()
|
||||
private val darkChange: MutableStateFlow<DarkChange> = MutableStateFlow(DarkChange.EMPTY)
|
||||
|
||||
private val factory =
|
||||
StatusOverlayHoverListenerFactory(
|
||||
context.resources,
|
||||
FakeConfigurationController(),
|
||||
darkDispatcher
|
||||
)
|
||||
private val view = TestableStatusContainer(context, viewOverlay)
|
||||
|
||||
private lateinit var looper: TestableLooper
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
looper = TestableLooper.get(this)
|
||||
whenever(darkDispatcher.darkChangeFlow()).thenReturn(darkChange)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onHoverStarted_addsOverlay() {
|
||||
view.setUpHoverListener()
|
||||
|
||||
view.hoverStarted()
|
||||
|
||||
assertThat(overlayDrawable).isNotNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onHoverEnded_removesOverlay() {
|
||||
view.setUpHoverListener()
|
||||
|
||||
view.hoverStarted() // stopped callback will be called only if hover has started
|
||||
view.hoverStopped()
|
||||
|
||||
verify(viewOverlay).clear()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onHoverStarted_overlayHasLightColor() {
|
||||
view.setUpHoverListener()
|
||||
|
||||
view.hoverStarted()
|
||||
|
||||
assertThat(overlayColor)
|
||||
.isEqualTo(context.resources.getColor(R.color.status_bar_icons_hover_color_light))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onDarkAwareHoverStarted_withBlackIcons_overlayHasDarkColor() {
|
||||
view.setUpDarkAwareHoverListener()
|
||||
setIconsTint(Color.BLACK)
|
||||
|
||||
view.hoverStarted()
|
||||
|
||||
assertThat(overlayColor)
|
||||
.isEqualTo(context.resources.getColor(R.color.status_bar_icons_hover_color_dark))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onHoverStarted_withBlackIcons_overlayHasLightColor() {
|
||||
view.setUpHoverListener()
|
||||
setIconsTint(Color.BLACK)
|
||||
|
||||
view.hoverStarted()
|
||||
|
||||
assertThat(overlayColor)
|
||||
.isEqualTo(context.resources.getColor(R.color.status_bar_icons_hover_color_light))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onDarkAwareHoverStarted_withWhiteIcons_overlayHasLightColor() {
|
||||
view.setUpDarkAwareHoverListener()
|
||||
setIconsTint(Color.WHITE)
|
||||
|
||||
view.hoverStarted()
|
||||
|
||||
assertThat(overlayColor)
|
||||
.isEqualTo(context.resources.getColor(R.color.status_bar_icons_hover_color_light))
|
||||
}
|
||||
|
||||
private fun View.setUpHoverListener() {
|
||||
setOnHoverListener(factory.createListener(view))
|
||||
attachView(view)
|
||||
}
|
||||
|
||||
private fun View.setUpDarkAwareHoverListener() {
|
||||
setOnHoverListener(factory.createDarkAwareListener(view))
|
||||
attachView(view)
|
||||
}
|
||||
|
||||
private fun attachView(view: View) {
|
||||
ViewUtils.attachView(view)
|
||||
// attaching is async so processAllMessages is required for view.repeatWhenAttached to run
|
||||
looper.processAllMessages()
|
||||
}
|
||||
|
||||
private val overlayDrawable: Drawable
|
||||
get() {
|
||||
verify(viewOverlay).add(overlayCaptor.capture())
|
||||
return overlayCaptor.value
|
||||
}
|
||||
|
||||
private val overlayColor
|
||||
get() = (overlayDrawable as PaintDrawable).paint.color
|
||||
|
||||
private fun setIconsTint(@ColorInt color: Int) {
|
||||
// passing empty ArrayList is equivalent to just accepting passed color as icons color
|
||||
darkChange.value = DarkChange(/* areas= */ ArrayList(), /* darkIntensity= */ 1f, color)
|
||||
}
|
||||
|
||||
private fun TestableStatusContainer.hoverStarted() {
|
||||
injectHoverEvent(hoverEvent(MotionEvent.ACTION_HOVER_ENTER))
|
||||
}
|
||||
|
||||
private fun TestableStatusContainer.hoverStopped() {
|
||||
injectHoverEvent(hoverEvent(MotionEvent.ACTION_HOVER_EXIT))
|
||||
}
|
||||
|
||||
class TestableStatusContainer(context: Context, private val mockOverlay: ViewGroupOverlay) :
|
||||
LinearLayout(context) {
|
||||
|
||||
fun injectHoverEvent(event: MotionEvent) = dispatchHoverEvent(event)
|
||||
|
||||
override fun getOverlay() = mockOverlay
|
||||
}
|
||||
|
||||
private fun hoverEvent(action: Int): MotionEvent {
|
||||
return MotionEvent.obtain(
|
||||
/* downTime= */ SystemClock.uptimeMillis(),
|
||||
/* eventTime= */ SystemClock.uptimeMillis(),
|
||||
/* action= */ action,
|
||||
/* x= */ 0f,
|
||||
/* y= */ 0f,
|
||||
/* metaState= */ 0
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user