From 65a7c2c56b75dbc3df07d7807ae2e88339cb229f Mon Sep 17 00:00:00 2001 From: Stevie Kideckel Date: Wed, 10 Mar 2021 08:02:36 +0000 Subject: [PATCH] Add API for setting a fixed list of RemoteViews on an AdapterView Bug: 182467111 Test: Sample app trying out different combinations of views and event listeners Test: atest RemoteViewsFixedCollectionAdapterTest Change-Id: Ia3fc0af0499cbfaa85e9eb1bf2a24f1e404f17a3 --- core/api/current.txt | 20 ++ .../widget/RemoteCollectionItemsAdapter.java | 217 +++++++++++++ core/java/android/widget/RemoteViews.java | 288 ++++++++++++++++++ 3 files changed, 525 insertions(+) create mode 100644 core/java/android/widget/RemoteCollectionItemsAdapter.java diff --git a/core/api/current.txt b/core/api/current.txt index ef93f45e96eff..63348965f2120 100644 --- a/core/api/current.txt +++ b/core/api/current.txt @@ -55028,6 +55028,7 @@ package android.widget { method public void setRelativeScrollPosition(@IdRes int, int); method @Deprecated public void setRemoteAdapter(int, @IdRes int, android.content.Intent); method public void setRemoteAdapter(@IdRes int, android.content.Intent); + method public void setRemoteAdapter(@IdRes int, @NonNull android.widget.RemoteViews.RemoteCollectionItems); method public void setScrollPosition(@IdRes int, int); method public void setShort(@IdRes int, String, short); method public void setString(@IdRes int, String, String); @@ -55067,6 +55068,25 @@ package android.widget { ctor public RemoteViews.ActionException(String); } + public static final class RemoteViews.RemoteCollectionItems implements android.os.Parcelable { + method public int describeContents(); + method public int getItemCount(); + method public long getItemId(int); + method @NonNull public android.widget.RemoteViews getItemView(int); + method public int getViewTypeCount(); + method public boolean hasStableIds(); + method public void writeToParcel(@NonNull android.os.Parcel, int); + field @NonNull public static final android.os.Parcelable.Creator CREATOR; + } + + public static final class RemoteViews.RemoteCollectionItems.Builder { + ctor public RemoteViews.RemoteCollectionItems.Builder(); + method @NonNull public android.widget.RemoteViews.RemoteCollectionItems.Builder addItem(long, @NonNull android.widget.RemoteViews); + method @NonNull public android.widget.RemoteViews.RemoteCollectionItems build(); + method @NonNull public android.widget.RemoteViews.RemoteCollectionItems.Builder setHasStableIds(boolean); + method @NonNull public android.widget.RemoteViews.RemoteCollectionItems.Builder setViewTypeCount(int); + } + public static class RemoteViews.RemoteResponse { ctor public RemoteViews.RemoteResponse(); method @NonNull public android.widget.RemoteViews.RemoteResponse addSharedElement(@IdRes int, @NonNull String); diff --git a/core/java/android/widget/RemoteCollectionItemsAdapter.java b/core/java/android/widget/RemoteCollectionItemsAdapter.java new file mode 100644 index 0000000000000..d84330828bf72 --- /dev/null +++ b/core/java/android/widget/RemoteCollectionItemsAdapter.java @@ -0,0 +1,217 @@ +/* + * Copyright (C) 2021 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 android.widget; + +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.util.SparseIntArray; +import android.view.View; +import android.view.ViewGroup; +import android.widget.RemoteViews.ColorResources; +import android.widget.RemoteViews.InteractionHandler; +import android.widget.RemoteViews.RemoteCollectionItems; + +import com.android.internal.R; + +import java.util.stream.IntStream; + +/** + * List {@link Adapter} backed by a {@link RemoteCollectionItems}. + * + * @hide + */ +class RemoteCollectionItemsAdapter extends BaseAdapter { + + private final int mViewTypeCount; + + private RemoteCollectionItems mItems; + private InteractionHandler mInteractionHandler; + private ColorResources mColorResources; + + private SparseIntArray mLayoutIdToViewType; + + RemoteCollectionItemsAdapter( + @NonNull RemoteCollectionItems items, + @NonNull InteractionHandler interactionHandler, + @NonNull ColorResources colorResources) { + // View type count can never increase after an adapter has been set on a ListView. + // Additionally, decreasing it could inhibit view recycling if the count were to back and + // forth between 3-2-3-2 for example. Therefore, the view type count, should be fixed for + // the lifetime of the adapter. + mViewTypeCount = items.getViewTypeCount(); + + mItems = items; + mInteractionHandler = interactionHandler; + mColorResources = colorResources; + + initLayoutIdToViewType(); + } + + /** + * Updates the data for the adapter, allowing recycling of views. Note that if the view type + * count has increased, a new adapter should be created and set on the AdapterView instead of + * calling this method. + */ + void setData( + @NonNull RemoteCollectionItems items, + @NonNull InteractionHandler interactionHandler, + @NonNull ColorResources colorResources) { + if (mViewTypeCount < items.getViewTypeCount()) { + throw new IllegalArgumentException( + "RemoteCollectionItemsAdapter cannot increase view type count after creation"); + } + + mItems = items; + mInteractionHandler = interactionHandler; + mColorResources = colorResources; + + initLayoutIdToViewType(); + + notifyDataSetChanged(); + } + + private void initLayoutIdToViewType() { + SparseIntArray previousLayoutIdToViewType = mLayoutIdToViewType; + mLayoutIdToViewType = new SparseIntArray(mViewTypeCount); + + int[] layoutIds = IntStream.range(0, mItems.getItemCount()) + .map(position -> mItems.getItemView(position).getLayoutId()) + .distinct() + .toArray(); + if (layoutIds.length > mViewTypeCount) { + throw new IllegalArgumentException( + "Collection items uses " + layoutIds.length + " distinct layouts, which is " + + "more than view type count of " + mViewTypeCount); + } + + // Tracks whether a layout id (by index, not value) has been assigned a view type. + boolean[] processedLayoutIdIndices = new boolean[layoutIds.length]; + // Tracks whether a view type has been assigned to a layout id already. + boolean[] assignedViewTypes = new boolean[mViewTypeCount]; + + if (previousLayoutIdToViewType != null) { + for (int i = 0; i < layoutIds.length; i++) { + int layoutId = layoutIds[i]; + // Copy over any previously used view types for layout ids in the collection to keep + // view types stable across data updates. + int previousViewType = previousLayoutIdToViewType.get(layoutId, -1); + // Skip this layout id if it wasn't assigned to a view type previously. + if (previousViewType < 0) continue; + + mLayoutIdToViewType.put(layoutId, previousViewType); + processedLayoutIdIndices[i] = true; + assignedViewTypes[previousViewType] = true; + } + } + + int lastViewType = -1; + for (int i = 0; i < layoutIds.length; i++) { + // If a view type has already been assigned to the layout id, skip it. + if (processedLayoutIdIndices[i]) continue; + + int layoutId = layoutIds[i]; + // If no view type is assigned for the layout id, choose the next possible value that + // isn't already assigned to a layout id. There is guaranteed to be some value available + // due to the prior validation logic that count(distinct layout ids) <= viewTypeCount. + int viewType = IntStream.range(lastViewType + 1, layoutIds.length) + .filter(type -> !assignedViewTypes[type]) + .findFirst() + .orElseThrow( + () -> new IllegalStateException( + "RemoteCollectionItems has more distinct layout ids than its " + + "view type count")); + mLayoutIdToViewType.put(layoutId, viewType); + processedLayoutIdIndices[i] = true; + assignedViewTypes[viewType] = true; + lastViewType = viewType; + } + } + + @Override + public int getCount() { + return mItems.getItemCount(); + } + + @Override + public RemoteViews getItem(int position) { + return mItems.getItemView(position); + } + + @Override + public long getItemId(int position) { + return mItems.getItemId(position); + } + + @Override + public int getItemViewType(int position) { + return mLayoutIdToViewType.get(mItems.getItemView(position).getLayoutId()); + } + + @Override + public int getViewTypeCount() { + return mViewTypeCount; + } + + @Override + public boolean hasStableIds() { + return mItems.hasStableIds(); + } + + @Nullable + @Override + public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) { + if (position >= getCount()) return null; + + RemoteViews item = mItems.getItemView(position); + item.addFlags(RemoteViews.FLAG_WIDGET_IS_COLLECTION_CHILD); + View reapplyView = getViewToReapply(convertView, item); + + // Reapply the RemoteViews if we can. + if (reapplyView != null) { + try { + item.reapply( + parent.getContext(), + reapplyView, + mInteractionHandler, + null /* size */, + mColorResources); + return reapplyView; + } catch (RuntimeException e) { + // We can't reapply for some reason, we'll fallback to an apply and inflate a + // new view. + } + } + + return item.apply( + parent.getContext(), + parent, + mInteractionHandler, + null /* size */, + mColorResources); + } + + /** Returns {@code convertView} if it can be used to reapply {@code item}, or null otherwise. */ + @Nullable + private static View getViewToReapply(@Nullable View convertView, @NonNull RemoteViews item) { + if (convertView == null) return null; + + Object layoutIdTag = convertView.getTag(R.id.widget_frame); + if (!(layoutIdTag instanceof Integer)) return null; + + return item.getLayoutId() == (Integer) layoutIdTag ? convertView : null; + } +} diff --git a/core/java/android/widget/RemoteViews.java b/core/java/android/widget/RemoteViews.java index d2f4cea69c88b..21589c9c7d98c 100644 --- a/core/java/android/widget/RemoteViews.java +++ b/core/java/android/widget/RemoteViews.java @@ -28,6 +28,7 @@ import android.annotation.Nullable; import android.annotation.Px; import android.annotation.StringRes; import android.annotation.StyleRes; +import android.annotation.SuppressLint; import android.app.Activity; import android.app.ActivityOptions; import android.app.ActivityThread; @@ -74,6 +75,7 @@ import android.util.ArrayMap; import android.util.DisplayMetrics; import android.util.IntArray; import android.util.Log; +import android.util.LongArray; import android.util.Pair; import android.util.SizeF; import android.util.SparseIntArray; @@ -112,6 +114,7 @@ import java.lang.invoke.MethodHandles; import java.lang.invoke.MethodType; import java.lang.reflect.Method; import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; import java.util.Iterator; import java.util.List; @@ -224,6 +227,7 @@ public class RemoteViews implements Parcelable, Filter { private static final int SET_VIEW_OUTLINE_RADIUS_TAG = 28; private static final int SET_ON_CHECKED_CHANGE_RESPONSE_TAG = 29; private static final int NIGHT_MODE_REFLECTION_ACTION_TAG = 30; + private static final int SET_REMOTE_COLLECTION_ITEMS_ADAPTER_TAG = 31; /** @hide **/ @IntDef(prefix = "MARGIN_", value = { @@ -899,6 +903,72 @@ public class RemoteViews implements Parcelable, Filter { ArrayList list; } + private static class SetRemoteCollectionItemListAdapterAction extends Action { + private final RemoteCollectionItems mItems; + + SetRemoteCollectionItemListAdapterAction(@IdRes int id, RemoteCollectionItems items) { + viewId = id; + mItems = items; + } + + SetRemoteCollectionItemListAdapterAction(Parcel parcel) { + viewId = parcel.readInt(); + mItems = parcel.readTypedObject(RemoteCollectionItems.CREATOR); + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(viewId); + dest.writeTypedObject(mItems, flags); + } + + @Override + public void apply(View root, ViewGroup rootParent, InteractionHandler handler, + ColorResources colorResources) throws ActionException { + View target = root.findViewById(viewId); + if (target == null) return; + + if (!(target instanceof AdapterView)) { + Log.e(LOG_TAG, "Cannot call setRemoteAdapter on a view which is not " + + "an AdapterView (id: " + viewId + ")"); + return; + } + + AdapterView adapterView = (AdapterView) target; + Adapter adapter = adapterView.getAdapter(); + // We can reuse the adapter if it's a RemoteCollectionItemsAdapter and the view type + // count hasn't increased. Note that AbsListView allocates a fixed size array for view + // recycling in setAdapter, so we must call setAdapter again if the number of view types + // increases. + if (adapter instanceof RemoteCollectionItemsAdapter + && adapter.getViewTypeCount() >= mItems.getViewTypeCount()) { + try { + ((RemoteCollectionItemsAdapter) adapter).setData( + mItems, handler, colorResources); + } catch (Throwable throwable) { + // setData should never failed with the validation in the items builder, but if + // it does, catch and rethrow. + throw new ActionException(throwable); + } + return; + } + + try { + adapterView.setAdapter( + new RemoteCollectionItemsAdapter(mItems, handler, colorResources)); + } catch (Throwable throwable) { + // This could throw if the AdapterView somehow doesn't accept BaseAdapter due to + // a type error. + throw new ActionException(throwable); + } + } + + @Override + public int getActionTag() { + return SET_REMOTE_COLLECTION_ITEMS_ADAPTER_TAG; + } + } + private class SetRemoteViewsAdapterIntent extends Action { public SetRemoteViewsAdapterIntent(@IdRes int id, Intent intent) { this.viewId = id; @@ -3543,6 +3613,8 @@ public class RemoteViews implements Parcelable, Filter { return new SetOnCheckedChangeResponse(parcel); case NIGHT_MODE_REFLECTION_ACTION_TAG: return new NightModeReflectionAction(parcel); + case SET_REMOTE_COLLECTION_ITEMS_ADAPTER_TAG: + return new SetRemoteCollectionItemListAdapterAction(parcel); default: throw new ActionException("Tag " + tag + " not found"); } @@ -4214,6 +4286,25 @@ public class RemoteViews implements Parcelable, Filter { addAction(new SetRemoteViewsAdapterList(viewId, list, viewTypeCount)); } + /** + * Creates a simple Adapter for the viewId specified. The viewId must point to an AdapterView, + * ie. {@link ListView}, {@link GridView}, {@link StackView} or {@link AdapterViewAnimator}. + * This is a simpler but less flexible approach to populating collection widgets. Its use is + * encouraged for most scenarios, as long as the total memory within the list of RemoteViews + * is relatively small (ie. doesn't contain large or numerous Bitmaps, see {@link + * RemoteViews#setImageViewBitmap}). In the case of numerous images, the use of API is still + * possible by setting image URIs instead of Bitmaps, see {@link RemoteViews#setImageViewUri}. + * + * This API is supported in the compatibility library for previous API levels, see + * RemoteViewsCompat. + * + * @param viewId The id of the {@link AdapterView}. + * @param items The items to display in the {@link AdapterView}. + */ + public void setRemoteAdapter(@IdRes int viewId, @NonNull RemoteCollectionItems items) { + addAction(new SetRemoteCollectionItemListAdapterAction(viewId, items)); + } + /** * Equivalent to calling {@link ListView#smoothScrollToPosition(int)}. * @@ -6026,6 +6117,203 @@ public class RemoteViews implements Parcelable, Filter { return true; } + /** Representation of a fixed list of items to be displayed in a RemoteViews collection. */ + public static final class RemoteCollectionItems implements Parcelable { + private final long[] mIds; + private final RemoteViews[] mViews; + private final boolean mHasStableIds; + private final int mViewTypeCount; + + RemoteCollectionItems( + long[] ids, RemoteViews[] views, boolean hasStableIds, int viewTypeCount) { + mIds = ids; + mViews = views; + mHasStableIds = hasStableIds; + mViewTypeCount = viewTypeCount; + if (ids.length != views.length) { + throw new IllegalArgumentException( + "RemoteCollectionItems has different number of ids and views"); + } + if (viewTypeCount < 1) { + throw new IllegalArgumentException("View type count must be >= 1"); + } + int layoutIdCount = (int) Arrays.stream(views) + .mapToInt(RemoteViews::getLayoutId) + .distinct() + .count(); + if (layoutIdCount > viewTypeCount) { + throw new IllegalArgumentException( + "View type count is set to " + viewTypeCount + ", but the collection " + + "contains " + layoutIdCount + " different layout ids"); + } + } + + RemoteCollectionItems(Parcel in) { + int length = in.readInt(); + mIds = new long[length]; + in.readLongArray(mIds); + mViews = new RemoteViews[length]; + in.readTypedArray(mViews, RemoteViews.CREATOR); + mHasStableIds = in.readBoolean(); + mViewTypeCount = in.readInt(); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + dest.writeInt(mIds.length); + dest.writeLongArray(mIds); + dest.writeTypedArray(mViews, flags); + dest.writeBoolean(mHasStableIds); + dest.writeInt(mViewTypeCount); + } + + /** + * Returns the id for {@code position}. See {@link #hasStableIds()} for whether this id + * should be considered meaningful across collection updates. + * + * @return Id for the position. + */ + public long getItemId(int position) { + return mIds[position]; + } + + /** + * Returns the {@link RemoteViews} to display at {@code position}. + * + * @return RemoteViews for the position. + */ + @NonNull + public RemoteViews getItemView(int position) { + return mViews[position]; + } + + /** + * Returns the number of elements in the collection. + * + * @return Count of items. + */ + public int getItemCount() { + return mIds.length; + } + + /** + * Returns the view type count for the collection when used in an adapter + * + * @return Count of view types for the collection when used in an adapter. + * @see android.widget.Adapter#getViewTypeCount() + */ + public int getViewTypeCount() { + return mViewTypeCount; + } + + /** + * Indicates whether the item ids are stable across changes to the underlying data. + * + * @return True if the same id always refers to the same object. + * @see android.widget.Adapter#hasStableIds() + */ + public boolean hasStableIds() { + return mHasStableIds; + } + + @NonNull + public static final Creator CREATOR = + new Creator() { + @NonNull + @Override + public RemoteCollectionItems createFromParcel(@NonNull Parcel source) { + return new RemoteCollectionItems(source); + } + + @NonNull + @Override + public RemoteCollectionItems[] newArray(int size) { + return new RemoteCollectionItems[size]; + } + }; + + /** Builder class for {@link RemoteCollectionItems} objects.*/ + public static final class Builder { + private final LongArray mIds = new LongArray(); + private final List mViews = new ArrayList<>(); + private boolean mHasStableIds; + private int mViewTypeCount; + + /** + * Adds a {@link RemoteViews} to the collection. + * + * @param id Id to associate with the row. Use {@link #setHasStableIds(boolean)} to + * indicate that ids are stable across changes to the collection. + * @param view RemoteViews to display for the row. + */ + @NonNull + // Covered by getItemId, getItemView, getItemCount. + @SuppressLint("MissingGetterMatchingBuilder") + public Builder addItem(long id, @NonNull RemoteViews view) { + if (view == null) throw new NullPointerException(); + if (view.hasMultipleLayouts()) { + throw new IllegalArgumentException( + "RemoteViews used in a RemoteCollectionItems cannot specify separate " + + "layouts for orientations or sizes."); + } + mIds.add(id); + mViews.add(view); + return this; + } + + /** + * Sets whether the item ids are stable across changes to the underlying data. + * + * @see android.widget.Adapter#hasStableIds() + */ + @NonNull + public Builder setHasStableIds(boolean hasStableIds) { + mHasStableIds = hasStableIds; + return this; + } + + /** + * Sets the view type count for the collection when used in an adapter. This can be set + * to the maximum number of different layout ids that will be used by RemoteViews in + * this collection. + * + * If this value is not set, then a value will be inferred from the provided items. As + * a result, the adapter may need to be recreated when the list is updated with + * previously unseen RemoteViews layouts for new items. + * + * @see android.widget.Adapter#getViewTypeCount() + */ + @NonNull + public Builder setViewTypeCount(int viewTypeCount) { + mViewTypeCount = viewTypeCount; + return this; + } + + /** Creates the {@link RemoteCollectionItems} defined by this builder. */ + @NonNull + public RemoteCollectionItems build() { + if (mViewTypeCount < 1) { + // If a view type count wasn't specified, set it to be the number of distinct + // layout ids used in the items. + mViewTypeCount = (int) mViews.stream() + .mapToInt(RemoteViews::getLayoutId) + .distinct() + .count(); + } + return new RemoteCollectionItems( + mIds.toArray(), + mViews.toArray(new RemoteViews[0]), + mHasStableIds, + Math.max(mViewTypeCount, 1)); + } + } + } + /** * Set the ID of the top-level view of the XML layout. *