blob: 5bc0cc8d636009cd70d9a25a1044131675aad5dd [file] [log] [blame]
/*
* 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();
}
}
}