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

Commit 0a5c7530 authored by Matías Hernández's avatar Matías Hernández Committed by Android (Google) Code Review
Browse files

Merge "Fix several issues related to CircularIconsPreference" into main

parents bb23b975 b676ca3a
Loading
Loading
Loading
Loading
+2 −2
Original line number Diff line number Diff line
@@ -58,8 +58,8 @@
            android:lineBreakWordStyle="phrase"
            android:maxLines="10"/>

        <!-- Circular icons (32dp) will be ImageViews under this LinearLayout -->
        <LinearLayout
        <!-- Circular icons (32dp) will be ImageViews under this container -->
        <com.android.settings.notification.modes.CircularIconsView
            android:id="@+id/circles_container"
            android:importantForAccessibility="noHideDescendants"
            android:orientation="horizontal"
+8 −7
Original line number Diff line number Diff line
@@ -61,13 +61,6 @@ abstract class AbstractZenModeHeaderController extends AbstractZenModePreference
        LayoutPreference preference = checkNotNull(screen.findPreference(getPreferenceKey()));
        preference.setSelectable(false);

        if (mHeaderController == null) {
            mHeaderController = EntityHeaderController.newInstance(
                    mFragment.getActivity(),
                    mFragment,
                    preference.findViewById(R.id.entity_header));
        }

        ImageView iconView = checkNotNull(preference.findViewById(R.id.entity_header_icon));
        ViewGroup.LayoutParams layoutParams = iconView.getLayoutParams();
        if (layoutParams.width != iconSizePx || layoutParams.height != iconSizePx) {
@@ -75,6 +68,14 @@ abstract class AbstractZenModeHeaderController extends AbstractZenModePreference
            layoutParams.height = iconSizePx;
            iconView.setLayoutParams(layoutParams);
        }

        if (mHeaderController == null) {
            mHeaderController = EntityHeaderController.newInstance(
                    mFragment.getActivity(),
                    mFragment,
                    preference.findViewById(R.id.entity_header));
            mHeaderController.done(false); // Make the space for the (unused) name go away.
        }
    }

    protected void updateIcon(Preference preference, @NonNull ZenMode zenMode,
+19 −183
Original line number Diff line number Diff line
@@ -16,236 +16,72 @@

package com.android.settings.notification.modes;

import static android.view.View.GONE;
import static android.view.View.VISIBLE;

import static com.google.common.base.Preconditions.checkNotNull;

import android.content.Context;
import android.content.res.Resources;
import android.graphics.drawable.Drawable;
import android.util.AttributeSet;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewTreeObserver;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;

import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.preference.PreferenceViewHolder;

import com.android.settings.R;
import com.android.settingslib.RestrictedPreference;

import com.google.common.base.Equivalence;
import com.google.common.collect.ImmutableList;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;

import java.util.List;
import java.util.concurrent.Executor;

public class CircularIconsPreference extends RestrictedPreference {

    private static final float DISABLED_ITEM_ALPHA = 0.3f;

    record LoadedIcons(ImmutableList<Drawable> icons, int extraItems) {
        static final LoadedIcons EMPTY = new LoadedIcons(ImmutableList.of(), 0);
    }

    private Executor mUiExecutor;

    // Chronologically, fields will be set top-to-bottom.
    @Nullable private CircularIconSet<?> mIconSet;
    @Nullable private ListenableFuture<List<Drawable>> mPendingLoadIconsFuture;
    @Nullable private LoadedIcons mLoadedIcons;
    private CircularIconSet<?> mIconSet = CircularIconSet.EMPTY;

    public CircularIconsPreference(Context context) {
        super(context);
        init(context);
    }

    @VisibleForTesting(otherwise = VisibleForTesting.NONE)
    public CircularIconsPreference(Context context, Executor uiExecutor) {
        this(context);
        mUiExecutor = uiExecutor;
        init();
    }

    public CircularIconsPreference(Context context, AttributeSet attrs) {
        super(context, attrs);
        init(context);
        init();
    }

    public CircularIconsPreference(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init(context);
        init();
    }

    public CircularIconsPreference(Context context, AttributeSet attrs, int defStyleAttr,
            int defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);
        init(context);
        init();
    }

    private void init(Context context) {
        mUiExecutor = context.getMainExecutor();
    private void init() {
        setLayoutResource(R.layout.preference_circular_icons);
    }

    <T> void displayIcons(CircularIconSet<T> iconSet) {
        displayIcons(iconSet, null);
    <T> void setIcons(CircularIconSet<T> iconSet) {
        setIcons(iconSet, null);
    }

    <T> void displayIcons(CircularIconSet<T> iconSet, @Nullable Equivalence<T> itemEquivalence) {
        if (mIconSet != null && mIconSet.hasSameItemsAs(iconSet, itemEquivalence)) {
    <T> void setIcons(CircularIconSet<T> iconSet, @Nullable Equivalence<T> itemEquivalence) {
        if (mIconSet.hasSameItemsAs(iconSet, itemEquivalence)) {
            return;
        }
        mIconSet = iconSet;

        mLoadedIcons = null;
        if (mPendingLoadIconsFuture != null) {
            mPendingLoadIconsFuture.cancel(true);
            mPendingLoadIconsFuture = null;
        }

        mIconSet = iconSet;
        notifyChanged();
    }

    @Override
    public void onBindViewHolder(PreferenceViewHolder holder) {
        super.onBindViewHolder(holder);
        CircularIconsView iconContainer = checkNotNull(
                (CircularIconsView) holder.findViewById(R.id.circles_container));

        LinearLayout iconContainer = checkNotNull(
                (LinearLayout) holder.findViewById(R.id.circles_container));
        bindIconContainer(iconContainer);
    }

    private void bindIconContainer(LinearLayout container) {
        if (mLoadedIcons != null) {
            // We have the icons ready to display already, show them.
            setDrawables(container, mLoadedIcons);
        } else if (mIconSet != null) {
            // We know what icons we want, but haven't yet loaded them.
            if (mIconSet.size() == 0) {
                container.setVisibility(View.GONE);
                mLoadedIcons = LoadedIcons.EMPTY;
                return;
            }
            container.setVisibility(View.VISIBLE);
            if (container.getMeasuredWidth() != 0) {
                startLoadingIcons(container, mIconSet);
            } else {
                container.getViewTreeObserver().addOnGlobalLayoutListener(
                        new ViewTreeObserver.OnGlobalLayoutListener() {
                            @Override
                            public void onGlobalLayout() {
                                container.getViewTreeObserver().removeOnGlobalLayoutListener(this);
                                notifyChanged();
                            }
                        }
                );
            }
        }
    }

    private void startLoadingIcons(LinearLayout container, CircularIconSet<?> iconSet) {
        Resources res = getContext().getResources();
        int availableSpace = container.getMeasuredWidth();
        int iconHorizontalSpace = res.getDimensionPixelSize(R.dimen.zen_mode_circular_icon_diameter)
                + res.getDimensionPixelSize(R.dimen.zen_mode_circular_icon_margin_between);
        int numIconsThatFit = availableSpace / iconHorizontalSpace;

        List<ListenableFuture<Drawable>> iconFutures;
        int extraItems;
        if (iconSet.size() > numIconsThatFit) {
            // Reserve one space for the (+xx) textview.
            int numIconsToShow = numIconsThatFit - 1;
            if (numIconsToShow < 0) {
                numIconsToShow = 0;
            }
            iconFutures = iconSet.getIcons(numIconsToShow);
            extraItems = iconSet.size() - numIconsToShow;
        } else {
            // Fit exactly or with remaining space.
            iconFutures = iconSet.getIcons();
            extraItems = 0;
        }

        // Display icons when all are ready (more consistent than randomly loading).
        mPendingLoadIconsFuture = Futures.allAsList(iconFutures);
        FutureUtil.whenDone(
                mPendingLoadIconsFuture,
                icons -> {
                    mLoadedIcons = new LoadedIcons(ImmutableList.copyOf(icons), extraItems);
                    notifyChanged(); // So that view is rebound and icons actually shown.
                },
                mUiExecutor);
    }

    private void setDrawables(LinearLayout container, LoadedIcons loadedIcons) {
        // Rearrange child views until we have <numImages> ImageViews...
        LayoutInflater inflater = LayoutInflater.from(getContext());
        int numImages = loadedIcons.icons.size();
        int numImageViews = getChildCount(container, ImageView.class);
        if (numImages > numImageViews) {
            for (int i = 0; i < numImages - numImageViews; i++) {
                ImageView imageView = (ImageView) inflater.inflate(
                        R.layout.preference_circular_icons_item, container, false);
                container.addView(imageView, 0);
            }
        } else if (numImageViews > numImages) {
            for (int i = 0; i < numImageViews - numImages; i++) {
                container.removeViewAt(0);
            }
        }
        // ... plus 0/1 TextViews at the end.
        if (loadedIcons.extraItems > 0 && !(getLastChild(container) instanceof TextView)) {
            TextView plusView = (TextView) inflater.inflate(
                    R.layout.preference_circular_icons_plus_item, container, false);
            container.addView(plusView);
        } else if (loadedIcons.extraItems == 0 && (getLastChild(container) instanceof TextView)) {
            container.removeViewAt(container.getChildCount() - 1);
        }

        // Show images (and +n if needed).
        for (int i = 0; i < numImages; i++) {
            ImageView imageView = (ImageView) container.getChildAt(i);
            imageView.setImageDrawable(loadedIcons.icons.get(i));
        }
        if (loadedIcons.extraItems > 0) {
            TextView textView = (TextView) checkNotNull(getLastChild(container));
            textView.setText(getContext().getString(R.string.zen_mode_plus_n_items,
                    loadedIcons.extraItems));
        }

        // Apply enabled/disabled style.
        for (int i = 0; i < container.getChildCount(); i++) {
            View child = container.getChildAt(i);
            child.setAlpha(isEnabled() ? 1.0f : DISABLED_ITEM_ALPHA);
        }
    }

    private static int getChildCount(ViewGroup parent, Class<? extends View> childClass) {
        int count = 0;
        for (int i = 0; i < parent.getChildCount(); i++) {
            if (childClass.isInstance(parent.getChildAt(i))) {
                count++;
            }
        }
        return count;
    }

    @Nullable
    private static View getLastChild(ViewGroup parent) {
        if (parent.getChildCount() == 0) {
            return null;
        }
        return parent.getChildAt(parent.getChildCount() - 1);
    }

    @VisibleForTesting(otherwise = VisibleForTesting.NONE)
    @Nullable
    LoadedIcons getLoadedIcons() {
        return mLoadedIcons;
        iconContainer.setVisibility(mIconSet != null && mIconSet.size() == 0 ? GONE : VISIBLE);
        iconContainer.setEnabled(isEnabled());
        iconContainer.setIcons(mIconSet);
    }
}
+232 −0
Original line number Diff line number Diff line
/*
 * 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.
 */

package com.android.settings.notification.modes;

import static com.google.common.base.Preconditions.checkNotNull;

import android.content.Context;
import android.content.res.Resources;
import android.graphics.drawable.Drawable;
import android.util.AttributeSet;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;

import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;

import com.android.settings.R;

import com.google.common.collect.ImmutableList;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;

import java.util.List;
import java.util.concurrent.Executor;

public class CircularIconsView extends LinearLayout {

    private static final float DISABLED_ITEM_ALPHA = 0.3f;

    record Icons(ImmutableList<Drawable> icons, int extraItems) { }

    private Executor mUiExecutor;
    private int mNumberOfCirclesThatFit;

    // Chronologically, fields will be set top-to-bottom.
    @Nullable private CircularIconSet<?> mIconSet;
    @Nullable private ListenableFuture<List<Drawable>> mPendingLoadIconsFuture;
    @Nullable private Icons mDisplayedIcons;

    public CircularIconsView(Context context) {
        super(context);
        setUiExecutor(context.getMainExecutor());
    }

    public CircularIconsView(Context context, AttributeSet attrs) {
        super(context, attrs);
        setUiExecutor(context.getMainExecutor());
    }

    public CircularIconsView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        setUiExecutor(context.getMainExecutor());
    }

    public CircularIconsView(Context context, AttributeSet attrs, int defStyleAttr,
            int defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);
        setUiExecutor(context.getMainExecutor());
    }

    @VisibleForTesting
    void setUiExecutor(Executor uiExecutor) {
        mUiExecutor = uiExecutor;
    }

    <T> void setIcons(CircularIconSet<T> iconSet) {
        if (mIconSet != null && mIconSet.equals(iconSet)) {
            return;
        }

        mIconSet = checkNotNull(iconSet);
        cancelPendingTasks();
        if (getMeasuredWidth() != 0) {
            startLoadingIcons(iconSet);
        }
    }

    private void cancelPendingTasks() {
        mDisplayedIcons = null;
        if (mPendingLoadIconsFuture != null) {
            mPendingLoadIconsFuture.cancel(true);
            mPendingLoadIconsFuture = null;
        }
    }

    @Override
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        super.onLayout(changed, left, top, right, bottom);

        int numFitting = getNumberOfCirclesThatFit();
        if (mNumberOfCirclesThatFit != numFitting) {
            // View has been measured for the first time OR its dimensions have changed since then.
            // Keep track, because we want to reload stuff if more (or less) items fit.
            mNumberOfCirclesThatFit = numFitting;

            if (mIconSet != null) {
                cancelPendingTasks();
                startLoadingIcons(mIconSet);
            }
        }
    }

    private int getNumberOfCirclesThatFit() {
        Resources res = getContext().getResources();
        int availableSpace = getMeasuredWidth();
        int iconHorizontalSpace = res.getDimensionPixelSize(R.dimen.zen_mode_circular_icon_diameter)
                + res.getDimensionPixelSize(R.dimen.zen_mode_circular_icon_margin_between);
        return availableSpace / iconHorizontalSpace;
    }

    private void startLoadingIcons(CircularIconSet<?> iconSet) {
        int numCirclesThatFit = getNumberOfCirclesThatFit();

        List<ListenableFuture<Drawable>> iconFutures;
        int extraItems;
        if (iconSet.size() > numCirclesThatFit) {
            // Reserve one space for the (+xx) textview.
            int numIconsToShow = numCirclesThatFit - 1;
            if (numIconsToShow < 0) {
                numIconsToShow = 0;
            }
            iconFutures = iconSet.getIcons(numIconsToShow);
            extraItems = iconSet.size() - numIconsToShow;
        } else {
            // Fit exactly or with remaining space.
            iconFutures = iconSet.getIcons();
            extraItems = 0;
        }

        // Display icons when all are ready (more consistent than randomly loading).
        mPendingLoadIconsFuture = Futures.allAsList(iconFutures);
        FutureUtil.whenDone(
                mPendingLoadIconsFuture,
                icons -> setDrawables(new Icons(ImmutableList.copyOf(icons), extraItems)),
                mUiExecutor);
    }

    private void setDrawables(Icons icons) {
        mDisplayedIcons = icons;

        // Rearrange child views until we have <numImages> ImageViews...
        LayoutInflater inflater = LayoutInflater.from(getContext());
        int numImages = icons.icons.size();
        int numImageViews = getChildCount(ImageView.class);
        if (numImages > numImageViews) {
            for (int i = 0; i < numImages - numImageViews; i++) {
                ImageView imageView = (ImageView) inflater.inflate(
                        R.layout.preference_circular_icons_item, this, false);
                addView(imageView, 0);
            }
        } else if (numImageViews > numImages) {
            for (int i = 0; i < numImageViews - numImages; i++) {
                removeViewAt(0);
            }
        }
        // ... plus 0/1 TextViews at the end.
        if (icons.extraItems > 0 && !(getLastChild() instanceof TextView)) {
            TextView plusView = (TextView) inflater.inflate(
                    R.layout.preference_circular_icons_plus_item, this, false);
            this.addView(plusView);
        } else if (icons.extraItems == 0 && (getLastChild() instanceof TextView)) {
            removeViewAt(getChildCount() - 1);
        }

        // Show images (and +n if needed).
        for (int i = 0; i < numImages; i++) {
            ImageView imageView = (ImageView) getChildAt(i);
            imageView.setImageDrawable(icons.icons.get(i));
        }
        if (icons.extraItems > 0) {
            TextView textView = (TextView) checkNotNull(getLastChild());
            textView.setText(getContext().getString(R.string.zen_mode_plus_n_items,
                    icons.extraItems));
        }

        applyEnabledDisabledAppearance(isEnabled());
    }

    @Override
    public void setEnabled(boolean enabled) {
        super.setEnabled(enabled);
        applyEnabledDisabledAppearance(isEnabled());
    }

    private void applyEnabledDisabledAppearance(boolean enabled) {
        for (int i = 0; i < getChildCount(); i++) {
            View child = getChildAt(i);
            child.setAlpha(enabled ? 1.0f : DISABLED_ITEM_ALPHA);
        }
    }

    private int getChildCount(Class<? extends View> childClass) {
        int count = 0;
        for (int i = 0; i < getChildCount(); i++) {
            if (childClass.isInstance(getChildAt(i))) {
                count++;
            }
        }
        return count;
    }

    @Nullable
    private View getLastChild() {
        if (getChildCount() == 0) {
            return null;
        }
        return getChildAt(getChildCount() - 1);
    }

    @VisibleForTesting(otherwise = VisibleForTesting.NONE)
    @Nullable
    Icons getDisplayedIcons() {
        return mDisplayedIcons;
    }
}
+13 −7
Original line number Diff line number Diff line
@@ -79,6 +79,7 @@ class IconUtil {
        @Px int innerSizePx = res.getDimensionPixelSize(R.dimen.zen_mode_header_inner_icon_size);

        Drawable base = composeIcons(
                context.getResources(),
                background,
                Utils.getColorAttr(context,
                        com.android.internal.R.attr.materialColorSecondaryContainer),
@@ -89,6 +90,7 @@ class IconUtil {
                innerSizePx);

        Drawable selected = composeIcons(
                context.getResources(),
                background,
                Utils.getColorAttr(context, com.android.internal.R.attr.materialColorPrimary),
                outerSizePx,
@@ -111,6 +113,7 @@ class IconUtil {
     */
    static Drawable makeIconPickerHeader(@NonNull Context context, Drawable icon) {
        return composeIconCircle(
                context.getResources(),
                Utils.getColorAttr(context,
                        com.android.internal.R.attr.materialColorSecondaryContainer),
                context.getResources().getDimensionPixelSize(
@@ -129,6 +132,7 @@ class IconUtil {
     */
    static Drawable makeIconPickerItem(@NonNull Context context, @DrawableRes int iconResId) {
        return composeIconCircle(
                context.getResources(),
                context.getColorStateList(R.color.modes_icon_selectable_background),
                context.getResources().getDimensionPixelSize(
                        R.dimen.zen_mode_icon_list_item_circle_diameter),
@@ -146,6 +150,7 @@ class IconUtil {
    static Drawable makeCircularIconPreferenceItem(@NonNull Context context,
            @DrawableRes int iconResId) {
        return composeIconCircle(
                context.getResources(),
                Utils.getColorAttr(context,
                        com.android.internal.R.attr.materialColorSecondaryContainer),
                context.getResources().getDimensionPixelSize(
@@ -166,6 +171,7 @@ class IconUtil {
        Resources res = context.getResources();
        if (Strings.isNullOrEmpty(displayName)) {
            return composeIconCircle(
                    context.getResources(),
                    Utils.getColorAttr(context,
                            com.android.internal.R.attr.materialColorTertiaryContainer),
                    res.getDimensionPixelSize(R.dimen.zen_mode_circular_icon_diameter),
@@ -204,17 +210,17 @@ class IconUtil {
        return new BitmapDrawable(context.getResources(), bitmap);
    }

    private static Drawable composeIconCircle(ColorStateList circleColor, @Px int circleDiameterPx,
            Drawable icon, ColorStateList iconColor, @Px int iconSizePx) {
        return composeIcons(new ShapeDrawable(new OvalShape()), circleColor, circleDiameterPx, icon,
                iconColor, iconSizePx);
    private static Drawable composeIconCircle(Resources res, ColorStateList circleColor,
            @Px int circleDiameterPx, Drawable icon, ColorStateList iconColor, @Px int iconSizePx) {
        return composeIcons(res, new ShapeDrawable(new OvalShape()), circleColor, circleDiameterPx,
                icon, iconColor, iconSizePx);
    }

    private static Drawable composeIcons(Drawable outer, ColorStateList outerColor,
    private static Drawable composeIcons(Resources res, Drawable outer, ColorStateList outerColor,
            @Px int outerSizePx, Drawable icon, ColorStateList iconColor, @Px int iconSizePx) {
        Drawable background = checkNotNull(outer.getConstantState()).newDrawable().mutate();
        Drawable background = checkNotNull(outer.getConstantState()).newDrawable(res).mutate();
        background.setTintList(outerColor);
        Drawable foreground = checkNotNull(icon.getConstantState()).newDrawable().mutate();
        Drawable foreground = checkNotNull(icon.getConstantState()).newDrawable(res).mutate();
        foreground.setTintList(iconColor);

        LayerDrawable layerDrawable = new LayerDrawable(new Drawable[] { background, foreground });
Loading