| /* |
| * Copyright (C) 2018 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.animation.Animator; |
| import android.animation.AnimatorListenerAdapter; |
| import android.animation.AnimatorSet; |
| import android.animation.ObjectAnimator; |
| import android.animation.ValueAnimator; |
| import android.app.settings.SettingsEnums; |
| import android.net.Uri; |
| import android.os.Bundle; |
| import android.os.Handler; |
| import android.text.TextUtils; |
| import android.view.Gravity; |
| import android.view.LayoutInflater; |
| import android.view.View; |
| import android.view.ViewGroup; |
| import android.view.ViewTreeObserver; |
| import android.view.animation.DecelerateInterpolator; |
| import android.widget.Button; |
| import android.widget.ImageView; |
| import android.widget.LinearLayout; |
| import android.widget.ProgressBar; |
| import android.widget.TextView; |
| |
| import androidx.annotation.NonNull; |
| import androidx.annotation.Nullable; |
| import androidx.core.graphics.drawable.IconCompat; |
| import androidx.fragment.app.Fragment; |
| import androidx.fragment.app.FragmentActivity; |
| import androidx.lifecycle.LifecycleObserver; |
| import androidx.lifecycle.LiveData; |
| import androidx.recyclerview.widget.LinearLayoutManager; |
| import androidx.recyclerview.widget.RecyclerView; |
| import androidx.slice.Slice; |
| import androidx.slice.SliceMetadata; |
| import androidx.slice.widget.SliceLiveData; |
| |
| import com.android.internal.annotations.VisibleForTesting; |
| import com.android.settings.R; |
| import com.android.settings.overlay.FeatureFactory; |
| import com.android.settings.panel.PanelLoggingContract.PanelClosedKeys; |
| import com.android.settingslib.core.instrumentation.MetricsFeatureProvider; |
| import com.android.settingslib.utils.ThreadUtils; |
| |
| import com.google.android.setupdesign.DividerItemDecoration; |
| |
| import java.util.ArrayList; |
| import java.util.Arrays; |
| import java.util.LinkedHashMap; |
| import java.util.List; |
| import java.util.Map; |
| |
| public class PanelFragment extends Fragment { |
| |
| private static final String TAG = "PanelFragment"; |
| |
| /** |
| * Duration of the animation entering the screen, in milliseconds. |
| */ |
| private static final int DURATION_ANIMATE_PANEL_EXPAND_MS = 250; |
| |
| /** |
| * Duration of the animation exiting the screen, in milliseconds. |
| */ |
| private static final int DURATION_ANIMATE_PANEL_COLLAPSE_MS = 200; |
| |
| /** |
| * Duration of timeout waiting for Slice data to bind, in milliseconds. |
| */ |
| private static final int DURATION_SLICE_BINDING_TIMEOUT_MS = 250; |
| |
| @VisibleForTesting |
| View mLayoutView; |
| private TextView mTitleView; |
| private Button mSeeMoreButton; |
| private Button mDoneButton; |
| private RecyclerView mPanelSlices; |
| private PanelContent mPanel; |
| private MetricsFeatureProvider mMetricsProvider; |
| private String mPanelClosedKey; |
| private LinearLayout mPanelHeader; |
| private ImageView mTitleIcon; |
| private LinearLayout mTitleGroup; |
| private LinearLayout mHeaderLayout; |
| private TextView mHeaderTitle; |
| private TextView mHeaderSubtitle; |
| private int mMaxHeight; |
| private boolean mPanelCreating; |
| private ProgressBar mProgressBar; |
| |
| private final Map<Uri, LiveData<Slice>> mSliceLiveData = new LinkedHashMap<>(); |
| |
| @VisibleForTesting |
| PanelSlicesLoaderCountdownLatch mPanelSlicesLoaderCountdownLatch; |
| |
| private ViewTreeObserver.OnPreDrawListener mOnPreDrawListener = () -> { |
| return false; |
| }; |
| |
| private final ViewTreeObserver.OnGlobalLayoutListener mPanelLayoutListener = |
| new ViewTreeObserver.OnGlobalLayoutListener() { |
| @Override |
| public void onGlobalLayout() { |
| if (mLayoutView.getHeight() > mMaxHeight) { |
| final ViewGroup.LayoutParams params = mLayoutView.getLayoutParams(); |
| params.height = mMaxHeight; |
| mLayoutView.setLayoutParams(params); |
| } |
| } |
| }; |
| |
| private final ViewTreeObserver.OnGlobalLayoutListener mOnGlobalLayoutListener = |
| new ViewTreeObserver.OnGlobalLayoutListener() { |
| @Override |
| public void onGlobalLayout() { |
| animateIn(); |
| if (mPanelSlices != null) { |
| mPanelSlices.getViewTreeObserver().removeOnGlobalLayoutListener(this); |
| } |
| mPanelCreating = false; |
| } |
| }; |
| |
| private PanelSlicesAdapter mAdapter; |
| |
| @Nullable |
| @Override |
| public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, |
| @Nullable Bundle savedInstanceState) { |
| mLayoutView = inflater.inflate(R.layout.panel_layout, container, false); |
| mLayoutView.getViewTreeObserver() |
| .addOnGlobalLayoutListener(mPanelLayoutListener); |
| mMaxHeight = getResources().getDimensionPixelSize(R.dimen.output_switcher_slice_max_height); |
| mPanelCreating = true; |
| createPanelContent(); |
| return mLayoutView; |
| } |
| |
| /** |
| * Animate the old panel out from the screen, then update the panel with new content once the |
| * animation is done. |
| * <p> |
| * Takes the entire panel and animates out from behind the navigation bar. |
| * <p> |
| * Call createPanelContent() once animation end. |
| */ |
| void updatePanelWithAnimation() { |
| mPanelCreating = true; |
| final View panelContent = mLayoutView.findViewById(R.id.panel_container); |
| final AnimatorSet animatorSet = buildAnimatorSet(mLayoutView, |
| 0.0f /* startY */, panelContent.getHeight() /* endY */, |
| 1.0f /* startAlpha */, 0.0f /* endAlpha */, |
| DURATION_ANIMATE_PANEL_COLLAPSE_MS); |
| |
| final ValueAnimator animator = new ValueAnimator(); |
| animator.setFloatValues(0.0f, 1.0f); |
| animatorSet.play(animator); |
| animatorSet.addListener(new AnimatorListenerAdapter() { |
| @Override |
| public void onAnimationEnd(Animator animation) { |
| createPanelContent(); |
| } |
| }); |
| animatorSet.start(); |
| } |
| |
| boolean isPanelCreating() { |
| return mPanelCreating; |
| } |
| |
| private void createPanelContent() { |
| final FragmentActivity activity = getActivity(); |
| if (activity == null) { |
| return; |
| } |
| |
| if (mLayoutView == null) { |
| activity.finish(); |
| return; |
| } |
| |
| final ViewGroup.LayoutParams params = mLayoutView.getLayoutParams(); |
| params.height = ViewGroup.LayoutParams.WRAP_CONTENT; |
| mLayoutView.setLayoutParams(params); |
| |
| 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); |
| mPanelHeader = mLayoutView.findViewById(R.id.panel_header); |
| mTitleIcon = mLayoutView.findViewById(R.id.title_icon); |
| mTitleGroup = mLayoutView.findViewById(R.id.title_group); |
| mHeaderLayout = mLayoutView.findViewById(R.id.header_layout); |
| mHeaderTitle = mLayoutView.findViewById(R.id.header_title); |
| mHeaderSubtitle = mLayoutView.findViewById(R.id.header_subtitle); |
| mProgressBar = mLayoutView.findViewById(R.id.progress_bar); |
| |
| // Make the panel layout gone here, to avoid janky animation when updating from old panel. |
| // We will make it visible once the panel is ready to load. |
| mPanelSlices.setVisibility(View.GONE); |
| // Remove the animator to avoid a RecyclerView crash. |
| mPanelSlices.setItemAnimator(null); |
| |
| final Bundle arguments = getArguments(); |
| final String callingPackageName = |
| arguments.getString(SettingsPanelActivity.KEY_CALLING_PACKAGE_NAME); |
| |
| mPanel = FeatureFactory.getFactory(activity) |
| .getPanelFeatureProvider() |
| .getPanel(activity, arguments); |
| |
| if (mPanel == null) { |
| activity.finish(); |
| return; |
| } |
| |
| mPanel.registerCallback(new LocalPanelCallback()); |
| if (mPanel instanceof LifecycleObserver) { |
| getLifecycle().addObserver((LifecycleObserver) mPanel); |
| } |
| |
| mMetricsProvider = FeatureFactory.getFactory(activity).getMetricsFeatureProvider(); |
| |
| updateProgressBar(); |
| |
| 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(); |
| |
| final IconCompat icon = mPanel.getIcon(); |
| final CharSequence title = mPanel.getTitle(); |
| final CharSequence subtitle = mPanel.getSubTitle(); |
| |
| if (icon != null || (subtitle != null && subtitle.length() > 0)) { |
| enablePanelHeader(icon, title, subtitle); |
| } else { |
| enableTitle(title); |
| } |
| |
| mSeeMoreButton.setOnClickListener(getSeeMoreListener()); |
| mDoneButton.setOnClickListener(getCloseListener()); |
| |
| if (mPanel.isCustomizedButtonUsed()) { |
| enableCustomizedButton(); |
| } else if (mPanel.getSeeMoreIntent() == null) { |
| // If getSeeMoreIntent() is null hide the mSeeMoreButton. |
| mSeeMoreButton.setVisibility(View.GONE); |
| } |
| |
| // Log panel opened. |
| mMetricsProvider.action( |
| 0 /* attribution */, |
| SettingsEnums.PAGE_VISIBLE /* opened panel - Action */, |
| mPanel.getMetricsCategory(), |
| callingPackageName, |
| 0 /* value */); |
| } |
| |
| private void enablePanelHeader(IconCompat icon, CharSequence title, CharSequence subtitle) { |
| mTitleView.setVisibility(View.GONE); |
| mPanelHeader.setVisibility(View.VISIBLE); |
| mPanelHeader.setAccessibilityPaneTitle(title); |
| mHeaderTitle.setText(title); |
| mHeaderSubtitle.setText(subtitle); |
| mHeaderSubtitle.setAccessibilityPaneTitle(subtitle); |
| if (icon != null) { |
| mTitleGroup.setVisibility(View.VISIBLE); |
| mHeaderLayout.setGravity(Gravity.LEFT); |
| mTitleIcon.setImageIcon(icon.toIcon(getContext())); |
| if (mPanel.getHeaderIconIntent() != null) { |
| mTitleIcon.setOnClickListener(getHeaderIconListener()); |
| mTitleIcon.setLayoutParams(new LinearLayout.LayoutParams( |
| ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)); |
| } else { |
| final int size = getResources().getDimensionPixelSize( |
| R.dimen.output_switcher_panel_icon_size); |
| mTitleIcon.setLayoutParams(new LinearLayout.LayoutParams(size, size)); |
| } |
| } else { |
| mTitleGroup.setVisibility(View.GONE); |
| mHeaderLayout.setGravity(Gravity.CENTER_HORIZONTAL); |
| } |
| } |
| |
| private void enableTitle(CharSequence title) { |
| mPanelHeader.setVisibility(View.GONE); |
| mTitleView.setVisibility(View.VISIBLE); |
| mTitleView.setAccessibilityPaneTitle(title); |
| mTitleView.setText(title); |
| } |
| |
| private void enableCustomizedButton() { |
| final CharSequence customTitle = mPanel.getCustomizedButtonTitle(); |
| if (TextUtils.isEmpty(customTitle)) { |
| mSeeMoreButton.setVisibility(View.GONE); |
| } else { |
| mSeeMoreButton.setVisibility(View.VISIBLE); |
| mSeeMoreButton.setText(customTitle); |
| } |
| } |
| |
| private void updateProgressBar() { |
| if (mPanel.isProgressBarVisible()) { |
| mProgressBar.setVisibility(View.VISIBLE); |
| } else { |
| mProgressBar.setVisibility(View.GONE); |
| } |
| } |
| |
| private void loadAllSlices() { |
| mSliceLiveData.clear(); |
| final List<Uri> sliceUris = mPanel.getSlices(); |
| mPanelSlicesLoaderCountdownLatch = new PanelSlicesLoaderCountdownLatch(sliceUris.size()); |
| |
| for (Uri uri : sliceUris) { |
| final LiveData<Slice> sliceLiveData = SliceLiveData.fromUri(getActivity(), uri, |
| (int type, Throwable source)-> { |
| removeSliceLiveData(uri); |
| mPanelSlicesLoaderCountdownLatch.markSliceLoaded(uri); |
| }); |
| |
| // Add slice first to make it in order. Will remove it later if there's an error. |
| mSliceLiveData.put(uri, sliceLiveData); |
| |
| sliceLiveData.observe(getViewLifecycleOwner(), slice -> { |
| |
| // If the Slice has already loaded, refresh list with slice data. |
| if (mPanelSlicesLoaderCountdownLatch.isSliceLoaded(uri)) { |
| if (mAdapter != null) { |
| int itemIndex = (new ArrayList<>(mSliceLiveData.keySet())).indexOf(uri); |
| mAdapter.notifyItemChanged(itemIndex); |
| } |
| return; |
| } |
| |
| /** |
| * Watching for the {@link Slice} to load. |
| * <p> |
| * If the Slice comes back {@code null} or with the Error attribute, if slice |
| * uri is not in the allowlist, remove the Slice data from the list, otherwise |
| * keep the Slice data. |
| * <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()) { |
| removeSliceLiveData(uri); |
| 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(); |
| }); |
| } |
| } |
| |
| private void removeSliceLiveData(Uri uri) { |
| final List<String> allowList = Arrays.asList( |
| getResources().getStringArray( |
| R.array.config_panel_keep_observe_uri)); |
| if (!allowList.contains(uri.toString())) { |
| mSliceLiveData.remove(uri); |
| } |
| } |
| |
| /** |
| * 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); |
| mPanelSlices.setVisibility(View.VISIBLE); |
| |
| final FragmentActivity activity = getActivity(); |
| if (activity == null) { |
| return; |
| } |
| final DividerItemDecoration itemDecoration = new DividerItemDecoration(activity); |
| itemDecoration |
| .setDividerCondition(DividerItemDecoration.DIVIDER_CONDITION_BOTH); |
| if (mPanelSlices.getItemDecorationCount() == 0) { |
| 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() /* startY */, 0.0f /* endY */, |
| 0.0f /* startAlpha */, 1.0f /* endAlpha */, |
| DURATION_ANIMATE_PANEL_EXPAND_MS); |
| 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); |
| } |
| |
| /** |
| * Build an {@link AnimatorSet} to animate the Panel, {@param parentView} in or out of the |
| * screen, based on the positional parameters {@param startY}, {@param endY}, the parameters |
| * for alpha changes {@param startAlpha}, {@param endAlpha}, and the {@param duration} in |
| * milliseconds. |
| */ |
| @NonNull |
| private static AnimatorSet buildAnimatorSet(@NonNull View parentView, float startY, float endY, |
| float startAlpha, float endAlpha, int duration) { |
| final View sheet = parentView.findViewById(R.id.panel_container); |
| final AnimatorSet animatorSet = new AnimatorSet(); |
| animatorSet.setDuration(duration); |
| animatorSet.setInterpolator(new DecelerateInterpolator()); |
| animatorSet.playTogether( |
| ObjectAnimator.ofFloat(sheet, View.TRANSLATION_Y, startY, endY), |
| ObjectAnimator.ofFloat(sheet, View.ALPHA, startAlpha, endAlpha)); |
| return animatorSet; |
| } |
| |
| @Override |
| public void onDestroyView() { |
| super.onDestroyView(); |
| |
| if (TextUtils.isEmpty(mPanelClosedKey)) { |
| mPanelClosedKey = PanelClosedKeys.KEY_OTHERS; |
| } |
| |
| if (mLayoutView != null) { |
| mLayoutView.getViewTreeObserver().removeOnGlobalLayoutListener(mPanelLayoutListener); |
| } |
| if (mPanel != null) { |
| mMetricsProvider.action( |
| 0 /* attribution */, |
| SettingsEnums.PAGE_HIDE, |
| mPanel.getMetricsCategory(), |
| mPanelClosedKey, |
| 0 /* value */); |
| } |
| } |
| |
| @VisibleForTesting |
| View.OnClickListener getSeeMoreListener() { |
| return (v) -> { |
| mPanelClosedKey = PanelClosedKeys.KEY_SEE_MORE; |
| final FragmentActivity activity = getActivity(); |
| if (mPanel.isCustomizedButtonUsed()) { |
| mPanel.onClickCustomizedButton(activity); |
| } else { |
| activity.startActivityForResult(mPanel.getSeeMoreIntent(), 0); |
| activity.finish(); |
| } |
| }; |
| } |
| |
| @VisibleForTesting |
| View.OnClickListener getCloseListener() { |
| return (v) -> { |
| mPanelClosedKey = PanelClosedKeys.KEY_DONE; |
| getActivity().finish(); |
| }; |
| } |
| |
| @VisibleForTesting |
| View.OnClickListener getHeaderIconListener() { |
| return (v) -> { |
| final FragmentActivity activity = getActivity(); |
| activity.startActivity(mPanel.getHeaderIconIntent()); |
| }; |
| } |
| |
| int getPanelViewType() { |
| return mPanel.getViewType(); |
| } |
| |
| class LocalPanelCallback implements PanelContentCallback { |
| |
| @Override |
| public void onCustomizedButtonStateChanged() { |
| ThreadUtils.postOnMainThread(() -> { |
| enableCustomizedButton(); |
| }); |
| } |
| |
| @Override |
| public void onHeaderChanged() { |
| ThreadUtils.postOnMainThread(() -> { |
| enablePanelHeader(mPanel.getIcon(), mPanel.getTitle(), mPanel.getSubTitle()); |
| }); |
| } |
| |
| @Override |
| public void forceClose() { |
| mPanelClosedKey = PanelClosedKeys.KEY_OTHERS; |
| getFragmentActivity().finish(); |
| } |
| |
| @Override |
| public void onTitleChanged() { |
| ThreadUtils.postOnMainThread(() -> { |
| enableTitle(mPanel.getTitle()); |
| }); |
| } |
| |
| @Override |
| public void onProgressBarVisibleChanged() { |
| ThreadUtils.postOnMainThread(() -> { |
| updateProgressBar(); |
| }); |
| } |
| |
| @VisibleForTesting |
| FragmentActivity getFragmentActivity() { |
| return getActivity(); |
| } |
| } |
| } |