OSDN Git Service

Add entry animation to Settings Panels.
authorMatthew Fritze <mfritze@google.com>
Fri, 8 Mar 2019 16:15:00 +0000 (08:15 -0800)
committerLinda Tseng <lindatseng@google.com>
Tue, 16 Apr 2019 16:29:04 +0000 (16:29 +0000)
Settings Panels as a dialog have a default animation for entering the
screen, but Slices complicate the animation. While the dialog enters the
screen, Slices begin to bind, thus changing the height of the dialog as
it enters, causing perceived bounce / jank in the animation.

This CL is cherry-picked/based on ag/6671083 but do the following modification:
(See the original commit message for the whole concept)

When trying to load all the Slices, there are few possible situations:
1. Slice starts loading slowly, starting at state LOADED_NONE
2. Slice is loading in progress, having state LOADED_PARTIAL
3. Slice is loaded, but there's error return from the Slice data (We don't
need to show the Slice in this case)
4. Slice is loaded, progress to state LOADED_ALL
5. Slice starts from state LOADED_NONE, but never progress to the next state
because it crashes at setting backend.

Notice that there are two cases that the state will stay at LOADED_NONE and
we can't distinguish them.

Hence, we decide to do the following:

If Slice is with error (case 3) we remove the slice from the list and mark it
loaded.

If Slice is loaded with LOADED_ALL (case 4, which is the ideal case), we mark
it as loaded.

In the other cases, we fire a handler to mark the slice loaded anyway after
250ms timeout.

When all the slices are marked loaded (which should happen after 250ms timeout,
we will animate the panel out.  Although there might be slices which are still
partial loaded, we can still have the slice in the panel once it is ready.
The panel might bounce/jank in this case, but at least it will still showing
correctly, and should show up smoothly in most cases.

The solution to this problem is twofold:
1. Load all Slices first
2. Create a custom animation to draw the panel once the recyclerview has
been laid out.

Test: Manual/Visual inspection
Test: make -j40 RunSettingsRobotests
Bug: 123942159

Change-Id: I639a707aa4ba3f906bd6f9752c92727aaba28142

res/layout/panel_layout.xml
res/layout/settings_panel.xml
res/values/themes.xml
src/com/android/settings/panel/PanelFragment.java
src/com/android/settings/panel/PanelSlicesAdapter.java
src/com/android/settings/panel/PanelSlicesLoaderCountdownLatch.java [new file with mode: 0644]
tests/robotests/src/com/android/settings/panel/PanelFragmentTest.java
tests/robotests/src/com/android/settings/panel/PanelSlicesAdapterTest.java
tests/robotests/src/com/android/settings/panel/PanelSlicesLoaderCountdownLatchTest.java [new file with mode: 0644]

index 3a8045f..c697afc 100644 (file)
     limitations under the License
   -->
 
-<!-- Note: There is a landscape version of this layout. -->
-<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
-              android:layout_width="match_parent"
-              android:layout_height="wrap_content"
-              android:orientation="vertical">
+<FrameLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:id="@+id/panel_container"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:background="@drawable/settings_panel_background" >
 
-    <TextView
-        android:id="@+id/panel_title"
+    <LinearLayout
         android:layout_width="match_parent"
         android:layout_height="wrap_content"
-        android:gravity="center"
-        android:paddingBottom="24dp"
-        android:paddingTop="18dp"
-        android:textColor="?android:attr/colorPrimary"
-        android:textSize="20sp"/>
+        android:orientation="vertical">
 
-    <include layout="@layout/panel_slice_list"/>
+        <TextView
+            android:id="@+id/panel_title"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:gravity="center"
+            android:paddingBottom="24dp"
+            android:paddingTop="18dp"
+            android:textColor="?android:attr/colorPrimary"
+            android:textSize="20sp"/>
 
-    <LinearLayout
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:orientation="horizontal"
-        android:paddingTop="8dp"
-        android:paddingBottom="8dp">
+        <include layout="@layout/horizontal_divider"/>
+
+        <!-- Note: There is a landscape version of panel_slice_list which supports scrolling. -->
+        <include layout="@layout/panel_slice_list"/>
+
+        <LinearLayout
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:orientation="horizontal"
+            android:paddingTop="8dp"
+            android:paddingBottom="8dp">
 
-        <Button
-            android:id="@+id/see_more"
-            style="@android:style/Widget.DeviceDefault.Button.Borderless.Colored"
-            android:layout_width="wrap_content"
-            android:layout_height="48dp"
-            android:layout_marginStart="12dp"
-            android:text="@string/see_more"/>
+            <Button
+                android:id="@+id/see_more"
+                style="@android:style/Widget.DeviceDefault.Button.Borderless.Colored"
+                android:layout_width="wrap_content"
+                android:layout_height="48dp"
+                android:layout_marginStart="12dp"
+                android:text="@string/see_more"/>
 
-        <Space
-            android:layout_weight="1"
-            android:layout_width="0dp"
-            android:layout_height="match_parent" />
+            <Space
+                android:layout_weight="1"
+                android:layout_width="0dp"
+                android:layout_height="match_parent" />
 
-        <Button
-            android:id="@+id/done"
-            style="@android:style/Widget.DeviceDefault.Button.Borderless.Colored"
-            android:layout_width="wrap_content"
-            android:layout_height="48dp"
-            android:layout_marginEnd="12dp"
-            android:text="@string/done"/>
+            <Button
+                android:id="@+id/done"
+                style="@android:style/Widget.DeviceDefault.Button.Borderless.Colored"
+                android:layout_width="wrap_content"
+                android:layout_height="48dp"
+                android:layout_marginEnd="12dp"
+                android:text="@string/done"/>
+        </LinearLayout>
     </LinearLayout>
-</LinearLayout>
\ No newline at end of file
+</FrameLayout>
\ No newline at end of file
index 3405ef0..0b0b227 100644 (file)
@@ -15,6 +15,5 @@
 -->
 <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
     android:id="@+id/main_content"
-    android:layout_height="match_parent"
-    android:layout_width="match_parent"
-    android:animateLayoutChanges="true"/>
\ No newline at end of file
+    android:layout_height="wrap_content"
+    android:layout_width="match_parent"/>
\ No newline at end of file
index 196e85b..80d9ec8 100644 (file)
 
     <!-- Note that Dialog themes do not set list dividers -->
     <style name="Theme.BottomDialog" parent="@*android:style/Theme.DeviceDefault.Settings.Dialog">
-        <item name="android:windowBackground">@drawable/settings_panel_background</item>
+        <item name="android:windowBackground">@null</item>
         <item name="android:dividerHorizontal">@*android:drawable/list_divider_material</item>
         <item name="android:windowNoTitle">true</item>
         <item name="android:listDivider">@*android:drawable/list_divider_material</item>
index 7f71925..173461c 100644 (file)
 
 package com.android.settings.panel;
 
+import android.animation.AnimatorSet;
+import android.animation.ObjectAnimator;
+import android.animation.ValueAnimator;
 import android.app.settings.SettingsEnums;
-import android.content.Context;
+import android.net.Uri;
 import android.os.Bundle;
+import android.os.Handler;
 import android.text.TextUtils;
 import android.view.LayoutInflater;
 import android.view.View;
 import android.view.ViewGroup;
+import android.view.ViewTreeObserver;
+import android.view.animation.DecelerateInterpolator;
+import android.view.animation.Interpolator;
 import android.widget.Button;
 import android.widget.TextView;
 
@@ -30,8 +37,12 @@ import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.fragment.app.Fragment;
 import androidx.fragment.app.FragmentActivity;
+import androidx.lifecycle.LiveData;
+import androidx.slice.Slice;
 import androidx.recyclerview.widget.LinearLayoutManager;
 import androidx.recyclerview.widget.RecyclerView;
+import androidx.slice.SliceMetadata;
+import androidx.slice.widget.SliceLiveData;
 
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.settings.R;
@@ -40,10 +51,24 @@ import com.android.settings.panel.PanelLoggingContract.PanelClosedKeys;
 import com.android.settingslib.core.instrumentation.MetricsFeatureProvider;
 import com.google.android.setupdesign.DividerItemDecoration;
 
+import java.util.ArrayList;
+import java.util.List;
+
 public class PanelFragment extends Fragment {
 
     private static final String TAG = "PanelFragment";
 
+    /**
+     * Duration of the animation entering or exiting the screen, in milliseconds.
+     */
+    private static final int DURATION_ANIMATE_PANEL_MS = 250;
+
+    /**
+     * Duration of timeout waiting for Slice data to bind, in milliseconds.
+     */
+    private static final int DURATION_SLICE_BINDING_TIMEOUT_MS = 250;
+
+    private View mLayoutView;
     private TextView mTitleView;
     private Button mSeeMoreButton;
     private Button mDoneButton;
@@ -53,20 +78,40 @@ public class PanelFragment extends Fragment {
     private MetricsFeatureProvider mMetricsProvider;
     private String mPanelClosedKey;
 
+    private final List<LiveData<Slice>> mSliceLiveData = new ArrayList<>();
+
     @VisibleForTesting
-    PanelSlicesAdapter mAdapter;
+    PanelSlicesLoaderCountdownLatch mPanelSlicesLoaderCountdownLatch;
+
+    private ViewTreeObserver.OnPreDrawListener mOnPreDrawListener = () -> {
+        return false;
+    };
+
+    private final ViewTreeObserver.OnGlobalLayoutListener mOnGlobalLayoutListener =
+            new ViewTreeObserver.OnGlobalLayoutListener() {
+        @Override
+        public void onGlobalLayout() {
+            animateIn();
+            if (mPanelSlices != null) {
+                mPanelSlices.getViewTreeObserver().removeOnGlobalLayoutListener(this);
+            }
+        }
+    };
+
+    private PanelSlicesAdapter mAdapter;
 
     @Nullable
     @Override
     public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
             @Nullable Bundle savedInstanceState) {
         final FragmentActivity activity = getActivity();
-        final View view = inflater.inflate(R.layout.panel_layout, container, false);
 
-        mPanelSlices = view.findViewById(R.id.panel_parent_layout);
-        mSeeMoreButton = view.findViewById(R.id.see_more);
-        mDoneButton = view.findViewById(R.id.done);
-        mTitleView = view.findViewById(R.id.panel_title);
+        mLayoutView = inflater.inflate(R.layout.panel_layout, container, false);
+
+        mPanelSlices = mLayoutView.findViewById(R.id.panel_parent_layout);
+        mSeeMoreButton = mLayoutView.findViewById(R.id.see_more);
+        mDoneButton = mLayoutView.findViewById(R.id.done);
+        mTitleView = mLayoutView.findViewById(R.id.panel_title);
 
         final Bundle arguments = getArguments();
         final String panelType =
@@ -82,6 +127,24 @@ public class PanelFragment extends Fragment {
                 .getPanel(activity, panelType, mediaPackageName);
 
         mMetricsProvider = FeatureFactory.getFactory(activity).getMetricsFeatureProvider();
+
+        mPanelSlices.setLayoutManager(new LinearLayoutManager((activity)));
+
+        // Add predraw listener to remove the animation and while we wait for Slices to load.
+        mLayoutView.getViewTreeObserver().addOnPreDrawListener(mOnPreDrawListener);
+
+        // Start loading Slices. When finished, the Panel will animate in.
+        loadAllSlices();
+
+        mTitleView.setText(mPanel.getTitle());
+        mSeeMoreButton.setOnClickListener(getSeeMoreListener());
+        mDoneButton.setOnClickListener(getCloseListener());
+
+        // If getSeeMoreIntent() is null, hide the mSeeMoreButton.
+        if (mPanel.getSeeMoreIntent() == null) {
+            mSeeMoreButton.setVisibility(View.GONE);
+        }
+
         // Log panel opened.
         mMetricsProvider.action(
                 0 /* attribution */,
@@ -90,27 +153,114 @@ public class PanelFragment extends Fragment {
                 callingPackageName,
                 0 /* value */);
 
-        mAdapter = new PanelSlicesAdapter(this, mPanel);
+        return mLayoutView;
+    }
 
-        mPanelSlices.setHasFixedSize(true);
-        mPanelSlices.setLayoutManager(new LinearLayoutManager((activity)));
-        mPanelSlices.setAdapter(mAdapter);
+    private void loadAllSlices() {
+        mSliceLiveData.clear();
+        final List<Uri> sliceUris = mPanel.getSlices();
+        mPanelSlicesLoaderCountdownLatch = new PanelSlicesLoaderCountdownLatch(sliceUris.size());
 
-        DividerItemDecoration itemDecoration = new DividerItemDecoration(getActivity());
-        itemDecoration.setDividerCondition(DividerItemDecoration.DIVIDER_CONDITION_BOTH);
-        mPanelSlices.addItemDecoration(itemDecoration);
+        for (Uri uri : sliceUris) {
+            final LiveData<Slice> sliceLiveData = SliceLiveData.fromUri(getActivity(), uri);
 
-        mTitleView.setText(mPanel.getTitle());
+            // Add slice first to make it in order.  Will remove it later if there's an error.
+            mSliceLiveData.add(sliceLiveData);
 
-        mSeeMoreButton.setOnClickListener(getSeeMoreListener());
-        mDoneButton.setOnClickListener(getCloseListener());
+            sliceLiveData.observe(getViewLifecycleOwner(), slice -> {
+                // If the Slice has already loaded, do nothing.
+                if (mPanelSlicesLoaderCountdownLatch.isSliceLoaded(uri)) {
+                    return;
+                }
 
-        //If getSeeMoreIntent() is null, hide the mSeeMoreButton.
-        if (mPanel.getSeeMoreIntent() == null) {
-            mSeeMoreButton.setVisibility(View.GONE);
+                /**
+                 * Watching for the {@link Slice} to load.
+                 * <p>
+                 *     If the Slice comes back {@code null} or with the Error attribute, remove the
+                 *     Slice data from the list, and mark the Slice as loaded.
+                 * <p>
+                 *     If the Slice has come back fully loaded, then mark the Slice as loaded.  No
+                 *     other actions required since we already have the Slice data in the list.
+                 * <p>
+                 *     If the Slice does not match the above condition, we will still want to mark
+                 *     it as loaded after 250ms timeout to avoid delay showing up the panel for
+                 *     too long.  Since we are still having the Slice data in the list, the Slice
+                 *     will show up later once it is loaded.
+                 */
+                final SliceMetadata metadata = SliceMetadata.from(getActivity(), slice);
+                if (slice == null || metadata.isErrorSlice()) {
+                    mSliceLiveData.remove(sliceLiveData);
+                    mPanelSlicesLoaderCountdownLatch.markSliceLoaded(uri);
+                } else if (metadata.getLoadingState() == SliceMetadata.LOADED_ALL) {
+                    mPanelSlicesLoaderCountdownLatch.markSliceLoaded(uri);
+                } else {
+                    Handler handler = new Handler();
+                    handler.postDelayed(() -> {
+                        mPanelSlicesLoaderCountdownLatch.markSliceLoaded(uri);
+                        loadPanelWhenReady();
+                    }, DURATION_SLICE_BINDING_TIMEOUT_MS);
+                }
+
+                loadPanelWhenReady();
+            });
         }
+    }
+
+    /**
+     * When all of the Slices have loaded for the first time, then we can setup the
+     * {@link RecyclerView}.
+     * <p>
+     *     When the Recyclerview has been laid out, we can begin the animation with the
+     *     {@link mOnGlobalLayoutListener}, which calls {@link #animateIn()}.
+     */
+    private void loadPanelWhenReady() {
+        if (mPanelSlicesLoaderCountdownLatch.isPanelReadyToLoad()) {
+            mAdapter = new PanelSlicesAdapter(
+                    this, mSliceLiveData, mPanel.getMetricsCategory());
+            mPanelSlices.setAdapter(mAdapter);
+            mPanelSlices.getViewTreeObserver()
+                    .addOnGlobalLayoutListener(mOnGlobalLayoutListener);
+
+            DividerItemDecoration itemDecoration = new DividerItemDecoration(getActivity());
+            itemDecoration
+                    .setDividerCondition(DividerItemDecoration.DIVIDER_CONDITION_BOTH);
+            mPanelSlices.addItemDecoration(itemDecoration);
+        }
+    }
+
+    /**
+     * Animate a Panel onto the screen.
+     * <p>
+     *     Takes the entire panel and animates in from behind the navigation bar.
+     * <p>
+     *     Relies on the Panel being having a fixed height to begin the animation.
+     */
+    private void animateIn() {
+        final View panelContent = mLayoutView.findViewById(R.id.panel_container);
+        final AnimatorSet animatorSet = buildAnimatorSet(mLayoutView, panelContent.getHeight(),
+                0.0f, new DecelerateInterpolator());
+        final ValueAnimator animator = new ValueAnimator();
+        animator.setFloatValues(0.0f, 1.0f);
+        animatorSet.play(animator);
+        animatorSet.start();
+        // Remove the predraw listeners on the Panel.
+        mLayoutView.getViewTreeObserver().removeOnPreDrawListener(mOnPreDrawListener);
+    }
 
-        return view;
+    /**
+     * Build an {@link AnimatorSet} to bring the Panel, {@param parentView}in our out of the screen,
+     * based on the positional parameters {@param startY}, {@param endY} and at the rate set by the
+     * {@param interpolator}.
+     */
+    @NonNull
+    private static AnimatorSet buildAnimatorSet(@NonNull View parentView, float startY, float endY,
+            @NonNull Interpolator interpolator) {
+        final View sheet = parentView.findViewById(R.id.panel_container);
+        final AnimatorSet animatorSet = new AnimatorSet();
+        animatorSet.setDuration(DURATION_ANIMATE_PANEL_MS);
+        animatorSet.setInterpolator(interpolator);
+        animatorSet.playTogether(ObjectAnimator.ofFloat(sheet, View.TRANSLATION_Y, startY, endY));
+        return animatorSet;
     }
 
     @Override
index 0eec534..e244413 100644 (file)
@@ -20,7 +20,6 @@ import static com.android.settings.slices.CustomSliceRegistry.MEDIA_OUTPUT_INDIC
 
 import android.app.settings.SettingsEnums;
 import android.content.Context;
-import android.net.Uri;
 import android.view.LayoutInflater;
 import android.view.View;
 import android.view.ViewGroup;
@@ -30,13 +29,13 @@ import androidx.annotation.VisibleForTesting;
 import androidx.lifecycle.LiveData;
 import androidx.recyclerview.widget.RecyclerView;
 import androidx.slice.Slice;
-import androidx.slice.widget.SliceLiveData;
 import androidx.slice.widget.SliceView;
 
 import com.android.settings.R;
 import com.android.settings.overlay.FeatureFactory;
 import com.google.android.setupdesign.DividerItemDecoration;
 
+import java.util.ArrayList;
 import java.util.List;
 
 /**
@@ -45,14 +44,15 @@ import java.util.List;
 public class PanelSlicesAdapter
         extends RecyclerView.Adapter<PanelSlicesAdapter.SliceRowViewHolder> {
 
-    private final List<Uri> mSliceUris;
+    private final List<LiveData<Slice>> mSliceLiveData;
+    private final int mMetricsCategory;
     private final PanelFragment mPanelFragment;
-    private final PanelContent mPanelContent;
 
-    public PanelSlicesAdapter(PanelFragment fragment, PanelContent panel) {
+    public PanelSlicesAdapter(
+            PanelFragment fragment, List<LiveData<Slice>> sliceLiveData, int metricsCategory) {
         mPanelFragment = fragment;
-        mSliceUris = panel.getSlices();
-        mPanelContent = panel;
+        mSliceLiveData = new ArrayList<>(sliceLiveData);
+        mMetricsCategory = metricsCategory;
     }
 
     @NonNull
@@ -62,67 +62,60 @@ public class PanelSlicesAdapter
         final LayoutInflater inflater = LayoutInflater.from(context);
         final View view = inflater.inflate(R.layout.panel_slice_row, viewGroup, false);
 
-        return new SliceRowViewHolder(view, mPanelContent);
+        return new SliceRowViewHolder(view);
     }
 
     @Override
     public void onBindViewHolder(@NonNull SliceRowViewHolder sliceRowViewHolder, int position) {
-        sliceRowViewHolder.onBind(mPanelFragment, mSliceUris.get(position));
+        sliceRowViewHolder.onBind(mSliceLiveData.get(position));
     }
 
     @Override
     public int getItemCount() {
-        return mSliceUris.size();
+        return mSliceLiveData.size();
     }
 
     @VisibleForTesting
-    List<Uri> getData() {
-        return mSliceUris;
+    List<LiveData<Slice>> getData() {
+        return mSliceLiveData;
     }
 
     /**
      * ViewHolder for binding Slices to SliceViews.
      */
-    public static class SliceRowViewHolder extends RecyclerView.ViewHolder
+    public class SliceRowViewHolder extends RecyclerView.ViewHolder
             implements DividerItemDecoration.DividedViewHolder {
 
-        private final PanelContent mPanelContent;
-
         private boolean mDividerAllowedAbove = true;
 
         @VisibleForTesting
-        LiveData<Slice> sliceLiveData;
-
-        @VisibleForTesting
         final SliceView sliceView;
 
-        public SliceRowViewHolder(View view, PanelContent panelContent) {
+        public SliceRowViewHolder(View view) {
             super(view);
             sliceView = view.findViewById(R.id.slice_view);
             sliceView.setMode(SliceView.MODE_LARGE);
             sliceView.showTitleItems(true);
-            mPanelContent = panelContent;
         }
 
-        public void onBind(PanelFragment fragment, Uri sliceUri) {
-            final Context context = sliceView.getContext();
-            sliceLiveData = SliceLiveData.fromUri(context, sliceUri);
-            sliceLiveData.observe(fragment.getViewLifecycleOwner(), sliceView);
+        public void onBind(LiveData<Slice> sliceLiveData) {
+            sliceLiveData.observe(mPanelFragment.getViewLifecycleOwner(), sliceView);
 
             // Do not show the divider above media devices switcher slice per request
-            if (sliceUri.equals(MEDIA_OUTPUT_INDICATOR_SLICE_URI)) {
+            final Slice slice = sliceLiveData.getValue();
+            if (slice != null && slice.getUri().equals(MEDIA_OUTPUT_INDICATOR_SLICE_URI)) {
                 mDividerAllowedAbove = false;
             }
 
             // Log Panel interaction
             sliceView.setOnSliceActionListener(
                     ((eventInfo, sliceItem) -> {
-                        FeatureFactory.getFactory(context)
+                        FeatureFactory.getFactory(sliceView.getContext())
                                 .getMetricsFeatureProvider()
                                 .action(0 /* attribution */,
                                         SettingsEnums.ACTION_PANEL_INTERACTION,
-                                        mPanelContent.getMetricsCategory(),
-                                        sliceUri.toString() /* log key */,
+                                        mMetricsCategory,
+                                        sliceLiveData.toString() /* log key */,
                                         eventInfo.actionType /* value */);
                     })
             );
diff --git a/src/com/android/settings/panel/PanelSlicesLoaderCountdownLatch.java b/src/com/android/settings/panel/PanelSlicesLoaderCountdownLatch.java
new file mode 100644 (file)
index 0000000..6137d6c
--- /dev/null
@@ -0,0 +1,83 @@
+/*
+ * Copyright (C) 2019 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.panel;
+
+import android.net.Uri;
+
+import androidx.slice.Slice;
+
+import java.util.HashSet;
+import java.util.Set;
+import java.util.concurrent.CountDownLatch;
+
+/**
+ * Helper class to isolate the work tracking all of the {@link Slice Slices} being loaded.
+ * <p>
+ *     Uses a {@link CountDownLatch} and a {@link Set} of Slices to track how many
+ *     Slices have been loaded. A Slice can only be counted as being loaded a single time, even
+ *     when they get updated later.
+ * <p>
+ *     To use the class, pass the number of expected Slices to load into the constructor. For
+ *     every Slice that loads, call {@link #markSliceLoaded(Uri)} with the corresponding
+ *     {@link Uri}. Then check if all of the Slices have loaded with
+ *     {@link #isPanelReadyToLoad()}, which will return {@code true} the first time after all
+ *     Slices have loaded.
+ */
+public class PanelSlicesLoaderCountdownLatch {
+    private final Set<Uri> mLoadedSlices;
+    private final CountDownLatch mCountDownLatch;
+    private boolean slicesReadyToLoad = false;
+
+    public PanelSlicesLoaderCountdownLatch(int countdownSize) {
+        mLoadedSlices = new HashSet<>();
+        mCountDownLatch = new CountDownLatch(countdownSize);
+    }
+
+    /**
+     * Checks if the {@param sliceUri} has been loaded: if not, then decrement the countdown
+     * latch, and if so, then do nothing.
+     */
+    public void markSliceLoaded(Uri sliceUri) {
+        if (mLoadedSlices.contains(sliceUri)) {
+            return;
+        }
+        mLoadedSlices.add(sliceUri);
+        mCountDownLatch.countDown();
+    }
+
+    /**
+     * @return {@code true} if the Slice has already been loaded.
+     */
+    public boolean isSliceLoaded(Uri uri) {
+        return mLoadedSlices.contains(uri);
+    }
+
+    /**
+     * @return {@code true} when all Slices have loaded, and the Panel has not yet been loaded.
+     */
+    public boolean isPanelReadyToLoad() {
+        /**
+         * Use {@link slicesReadyToLoad} to track whether or not the Panel has been loaded. We
+         * only want to animate the Panel a single time.
+         */
+        if ((mCountDownLatch.getCount() == 0) && !slicesReadyToLoad) {
+            slicesReadyToLoad = true;
+            return true;
+        }
+        return false;
+    }
+}
\ No newline at end of file
index d606ac7..fd2e806 100644 (file)
@@ -26,6 +26,7 @@ import static org.mockito.Mockito.verify;
 
 import android.app.settings.SettingsEnums;
 import android.content.Context;
+import android.net.Uri;
 import android.os.Bundle;
 import android.view.LayoutInflater;
 import android.view.View;
@@ -81,12 +82,16 @@ public class PanelFragmentTest {
     }
 
     @Test
-    public void onCreateView_adapterGetsDataset() {
+    public void onCreateView_countdownLatch_setup() {
         mPanelFragment.onCreateView(LayoutInflater.from(mContext),
                 new LinearLayout(mContext), null);
-        PanelSlicesAdapter adapter = mPanelFragment.mAdapter;
+        PanelSlicesLoaderCountdownLatch countdownLatch =
+                mPanelFragment.mPanelSlicesLoaderCountdownLatch;
+        for (Uri sliecUri: mFakePanelContent.getSlices()) {
+            countdownLatch.markSliceLoaded(sliecUri);
+        }
 
-        assertThat(adapter.getData()).containsAllIn(mFakePanelContent.getSlices());
+        assertThat(countdownLatch.isPanelReadyToLoad()).isTrue();
     }
 
     @Test
index 9795b55..14a7db9 100644 (file)
@@ -23,41 +23,53 @@ import static com.google.common.truth.Truth.assertThat;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.doReturn;
 import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.when;
 
-import android.app.settings.SettingsEnums;
 import android.content.Context;
-import android.content.Intent;
 import android.net.Uri;
 import android.view.ViewGroup;
 import android.widget.FrameLayout;
 
+import androidx.lifecycle.LiveData;
+import androidx.slice.Slice;
+
 import com.android.settings.R;
+import com.android.settings.slices.CustomSliceRegistry;
 import com.android.settings.testutils.FakeFeatureFactory;
 
 import org.junit.Before;
 import org.junit.runner.RunWith;
-import org.robolectric.Robolectric;
-
 import org.junit.Test;
-
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.robolectric.Robolectric;
 import org.robolectric.RobolectricTestRunner;
 import org.robolectric.RuntimeEnvironment;
 import org.robolectric.android.controller.ActivityController;
 
-import java.util.Arrays;
+import java.util.ArrayList;
 import java.util.List;
 
 @RunWith(RobolectricTestRunner.class)
 public class PanelSlicesAdapterTest {
 
+    private static final Uri DATA_URI = CustomSliceRegistry.DATA_USAGE_SLICE_URI;
+
     private Context mContext;
     private PanelFragment mPanelFragment;
-    private FakePanelContent mFakePanelContent;
-    private FakeFeatureFactory mFakeFeatureFactory;
     private PanelFeatureProvider mPanelFeatureProvider;
+    private FakeFeatureFactory mFakeFeatureFactory;
+    private FakePanelContent mFakePanelContent;
+    private List<LiveData<Slice>> mData = new ArrayList<>();
+
+    @Mock
+    private LiveData<Slice> mLiveData;
+
+    private Slice mSlice;
 
     @Before
     public void setUp() {
+        MockitoAnnotations.initMocks(this);
         mContext = RuntimeEnvironment.application;
 
         mPanelFeatureProvider = spy(new PanelFeatureProviderImpl());
@@ -76,12 +88,22 @@ public class PanelSlicesAdapterTest {
                                 .get()
                                 .getSupportFragmentManager()
                                 .findFragmentById(R.id.main_content));
+
+    }
+
+    private void constructTestLiveData(Uri uri) {
+        // Create a slice to return for the LiveData
+        mSlice = spy(new Slice());
+        doReturn(uri).when(mSlice).getUri();
+        when(mLiveData.getValue()).thenReturn(mSlice);
+        mData.add(mLiveData);
     }
 
     @Test
     public void onCreateViewHolder_returnsSliceRowViewHolder() {
+        constructTestLiveData(DATA_URI);
         final PanelSlicesAdapter adapter =
-                new PanelSlicesAdapter(mPanelFragment, mFakePanelContent);
+                new PanelSlicesAdapter(mPanelFragment, mData, 0 /* metrics category */);
         final ViewGroup view = new FrameLayout(mContext);
         final PanelSlicesAdapter.SliceRowViewHolder viewHolder =
                 adapter.onCreateViewHolder(view, 0);
@@ -90,23 +112,10 @@ public class PanelSlicesAdapterTest {
     }
 
     @Test
-    public void onBindViewHolder_bindsSlice() {
-        final PanelSlicesAdapter adapter =
-                new PanelSlicesAdapter(mPanelFragment, mFakePanelContent);
-        final int position = 0;
-        final ViewGroup view = new FrameLayout(mContext);
-        final PanelSlicesAdapter.SliceRowViewHolder viewHolder =
-                adapter.onCreateViewHolder(view, 0 /* view type*/);
-
-        adapter.onBindViewHolder(viewHolder, position);
-
-        assertThat(viewHolder.sliceLiveData).isNotNull();
-    }
-
-    @Test
     public void nonMediaOutputIndicatorSlice_shouldAllowDividerAboveAndBelow() {
+        constructTestLiveData(DATA_URI);
         final PanelSlicesAdapter adapter =
-                new PanelSlicesAdapter(mPanelFragment, mFakePanelContent);
+                new PanelSlicesAdapter(mPanelFragment, mData, 0 /* metrics category */);
         final int position = 0;
         final ViewGroup view = new FrameLayout(mContext);
         final PanelSlicesAdapter.SliceRowViewHolder viewHolder =
@@ -120,32 +129,10 @@ public class PanelSlicesAdapterTest {
 
     @Test
     public void mediaOutputIndicatorSlice_shouldNotAllowDividerAbove() {
-        PanelContent mediaOutputIndicatorSlicePanelContent = new PanelContent() {
-            @Override
-            public CharSequence getTitle() {
-                return "title";
-            }
-
-            @Override
-            public List<Uri> getSlices() {
-                return Arrays.asList(
-                        MEDIA_OUTPUT_INDICATOR_SLICE_URI
-                );
-            }
-
-            @Override
-            public Intent getSeeMoreIntent() {
-                return new Intent();
-            }
-
-            @Override
-            public int getMetricsCategory() {
-                return SettingsEnums.TESTING;
-            }
-        };
+        constructTestLiveData(MEDIA_OUTPUT_INDICATOR_SLICE_URI);
 
         final PanelSlicesAdapter adapter =
-                new PanelSlicesAdapter(mPanelFragment, mediaOutputIndicatorSlicePanelContent);
+                new PanelSlicesAdapter(mPanelFragment, mData, 0 /* metrics category */);
         final int position = 0;
         final ViewGroup view = new FrameLayout(mContext);
         final PanelSlicesAdapter.SliceRowViewHolder viewHolder =
diff --git a/tests/robotests/src/com/android/settings/panel/PanelSlicesLoaderCountdownLatchTest.java b/tests/robotests/src/com/android/settings/panel/PanelSlicesLoaderCountdownLatchTest.java
new file mode 100644 (file)
index 0000000..dd8a91f
--- /dev/null
@@ -0,0 +1,92 @@
+/*
+ * Copyright (C) 2019 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.panel;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.content.Context;
+import android.net.Uri;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.RuntimeEnvironment;
+
+@RunWith(RobolectricTestRunner.class)
+public class PanelSlicesLoaderCountdownLatchTest {
+
+    private Context mContext;
+    private PanelSlicesLoaderCountdownLatch mSliceCountdownLatch;
+
+    private static final Uri[] URIS = new Uri[] {
+      Uri.parse("content://testUri"),
+      Uri.parse("content://wowUri"),
+      Uri.parse("content://boxTurtle")
+    };
+
+    @Before
+    public void setUp() {
+        mContext = RuntimeEnvironment.application;
+        mSliceCountdownLatch = new PanelSlicesLoaderCountdownLatch(URIS.length);
+    }
+
+
+    @Test
+    public void hasLoaded_newObject_returnsFalse() {
+        assertThat(mSliceCountdownLatch.isSliceLoaded(URIS[0])).isFalse();
+    }
+
+    @Test
+    public void hasLoaded_markSliceLoaded_returnsTrue() {
+        mSliceCountdownLatch.markSliceLoaded(URIS[0]);
+
+        assertThat(mSliceCountdownLatch.isSliceLoaded(URIS[0])).isTrue();
+    }
+
+    @Test
+    public void markSliceLoaded_onlyCountsDownUniqueUris() {
+        for (int i = 0; i < URIS.length; i++) {
+            mSliceCountdownLatch.markSliceLoaded(URIS[0]);
+        }
+
+        assertThat(mSliceCountdownLatch.isPanelReadyToLoad()).isFalse();
+    }
+
+    @Test
+    public void areSlicesReadyToLoad_allSlicesLoaded_returnsTrue() {
+        for (int i = 0; i < URIS.length; i++) {
+            mSliceCountdownLatch.markSliceLoaded(URIS[i]);
+        }
+
+        assertThat(mSliceCountdownLatch.isPanelReadyToLoad()).isTrue();
+    }
+
+    @Test
+    public void areSlicesReadyToLoad_onlyReturnsTrueOnce() {
+        for (int i = 0; i < URIS.length; i++) {
+            mSliceCountdownLatch.markSliceLoaded(URIS[i]);
+        }
+
+        // Verify that it returns true once
+        assertThat(mSliceCountdownLatch.isPanelReadyToLoad()).isTrue();
+        // Verify the second call returns false without external state change
+        assertThat(mSliceCountdownLatch.isPanelReadyToLoad()).isFalse();
+    }
+}
\ No newline at end of file