1 /* 2 * Copyright (C) 2018 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.settings.panel; 18 19 import android.animation.Animator; 20 import android.animation.AnimatorListenerAdapter; 21 import android.animation.AnimatorSet; 22 import android.animation.ObjectAnimator; 23 import android.animation.ValueAnimator; 24 import android.app.settings.SettingsEnums; 25 import android.net.Uri; 26 import android.os.Bundle; 27 import android.os.Handler; 28 import android.text.TextUtils; 29 import android.view.LayoutInflater; 30 import android.view.View; 31 import android.view.ViewGroup; 32 import android.view.ViewTreeObserver; 33 import android.view.animation.DecelerateInterpolator; 34 import android.widget.Button; 35 import android.widget.ImageView; 36 import android.widget.LinearLayout; 37 import android.widget.TextView; 38 39 import androidx.annotation.NonNull; 40 import androidx.annotation.Nullable; 41 import androidx.core.graphics.drawable.IconCompat; 42 import androidx.fragment.app.Fragment; 43 import androidx.fragment.app.FragmentActivity; 44 import androidx.lifecycle.LifecycleObserver; 45 import androidx.lifecycle.LiveData; 46 import androidx.recyclerview.widget.LinearLayoutManager; 47 import androidx.recyclerview.widget.RecyclerView; 48 import androidx.slice.Slice; 49 import androidx.slice.SliceMetadata; 50 import androidx.slice.widget.SliceLiveData; 51 52 import com.android.internal.annotations.VisibleForTesting; 53 import com.android.settings.R; 54 import com.android.settings.overlay.FeatureFactory; 55 import com.android.settings.panel.PanelLoggingContract.PanelClosedKeys; 56 import com.android.settingslib.core.instrumentation.MetricsFeatureProvider; 57 import com.android.settingslib.utils.ThreadUtils; 58 59 import com.google.android.setupdesign.DividerItemDecoration; 60 61 import java.util.Arrays; 62 import java.util.LinkedHashMap; 63 import java.util.List; 64 import java.util.Map; 65 66 public class PanelFragment extends Fragment { 67 68 private static final String TAG = "PanelFragment"; 69 70 /** 71 * Duration of the animation entering the screen, in milliseconds. 72 */ 73 private static final int DURATION_ANIMATE_PANEL_EXPAND_MS = 250; 74 75 /** 76 * Duration of the animation exiting the screen, in milliseconds. 77 */ 78 private static final int DURATION_ANIMATE_PANEL_COLLAPSE_MS = 200; 79 80 /** 81 * Duration of timeout waiting for Slice data to bind, in milliseconds. 82 */ 83 private static final int DURATION_SLICE_BINDING_TIMEOUT_MS = 250; 84 85 @VisibleForTesting 86 View mLayoutView; 87 private TextView mTitleView; 88 private Button mSeeMoreButton; 89 private Button mDoneButton; 90 private RecyclerView mPanelSlices; 91 private PanelContent mPanel; 92 private MetricsFeatureProvider mMetricsProvider; 93 private String mPanelClosedKey; 94 private LinearLayout mPanelHeader; 95 private ImageView mTitleIcon; 96 private TextView mHeaderTitle; 97 private TextView mHeaderSubtitle; 98 private int mMaxHeight; 99 private View mFooterDivider; 100 101 private final Map<Uri, LiveData<Slice>> mSliceLiveData = new LinkedHashMap<>(); 102 103 @VisibleForTesting 104 PanelSlicesLoaderCountdownLatch mPanelSlicesLoaderCountdownLatch; 105 106 private ViewTreeObserver.OnPreDrawListener mOnPreDrawListener = () -> { 107 return false; 108 }; 109 110 private final ViewTreeObserver.OnGlobalLayoutListener mPanelLayoutListener = 111 new ViewTreeObserver.OnGlobalLayoutListener() { 112 @Override 113 public void onGlobalLayout() { 114 if (mLayoutView.getHeight() > mMaxHeight) { 115 final ViewGroup.LayoutParams params = mLayoutView.getLayoutParams(); 116 params.height = mMaxHeight; 117 mLayoutView.setLayoutParams(params); 118 } 119 } 120 }; 121 122 private final ViewTreeObserver.OnGlobalLayoutListener mOnGlobalLayoutListener = 123 new ViewTreeObserver.OnGlobalLayoutListener() { 124 @Override 125 public void onGlobalLayout() { 126 animateIn(); 127 if (mPanelSlices != null) { 128 mPanelSlices.getViewTreeObserver().removeOnGlobalLayoutListener(this); 129 } 130 } 131 }; 132 133 private PanelSlicesAdapter mAdapter; 134 135 @Nullable 136 @Override onCreateView(@onNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState)137 public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, 138 @Nullable Bundle savedInstanceState) { 139 mLayoutView = inflater.inflate(R.layout.panel_layout, container, false); 140 mLayoutView.getViewTreeObserver() 141 .addOnGlobalLayoutListener(mPanelLayoutListener); 142 mMaxHeight = getResources().getDimensionPixelSize(R.dimen.output_switcher_slice_max_height); 143 createPanelContent(); 144 return mLayoutView; 145 } 146 147 /** 148 * Animate the old panel out from the screen, then update the panel with new content once the 149 * animation is done. 150 * <p> 151 * Takes the entire panel and animates out from behind the navigation bar. 152 * <p> 153 * Call createPanelContent() once animation end. 154 */ updatePanelWithAnimation()155 void updatePanelWithAnimation() { 156 final View panelContent = mLayoutView.findViewById(R.id.panel_container); 157 final AnimatorSet animatorSet = buildAnimatorSet(mLayoutView, 158 0.0f /* startY */, panelContent.getHeight() /* endY */, 159 1.0f /* startAlpha */, 0.0f /* endAlpha */, 160 DURATION_ANIMATE_PANEL_COLLAPSE_MS); 161 162 final ValueAnimator animator = new ValueAnimator(); 163 animator.setFloatValues(0.0f, 1.0f); 164 animatorSet.play(animator); 165 animatorSet.addListener(new AnimatorListenerAdapter() { 166 @Override 167 public void onAnimationEnd(Animator animation) { 168 createPanelContent(); 169 } 170 }); 171 animatorSet.start(); 172 } 173 createPanelContent()174 private void createPanelContent() { 175 final FragmentActivity activity = getActivity(); 176 if (mLayoutView == null) { 177 activity.finish(); 178 } 179 final ViewGroup.LayoutParams params = mLayoutView.getLayoutParams(); 180 params.height = ViewGroup.LayoutParams.WRAP_CONTENT; 181 mLayoutView.setLayoutParams(params); 182 183 mPanelSlices = mLayoutView.findViewById(R.id.panel_parent_layout); 184 mSeeMoreButton = mLayoutView.findViewById(R.id.see_more); 185 mDoneButton = mLayoutView.findViewById(R.id.done); 186 mTitleView = mLayoutView.findViewById(R.id.panel_title); 187 mPanelHeader = mLayoutView.findViewById(R.id.panel_header); 188 mTitleIcon = mLayoutView.findViewById(R.id.title_icon); 189 mHeaderTitle = mLayoutView.findViewById(R.id.header_title); 190 mHeaderSubtitle = mLayoutView.findViewById(R.id.header_subtitle); 191 mFooterDivider = mLayoutView.findViewById(R.id.footer_divider); 192 193 // Make the panel layout gone here, to avoid janky animation when updating from old panel. 194 // We will make it visible once the panel is ready to load. 195 mPanelSlices.setVisibility(View.GONE); 196 197 final Bundle arguments = getArguments(); 198 final String callingPackageName = 199 arguments.getString(SettingsPanelActivity.KEY_CALLING_PACKAGE_NAME); 200 201 mPanel = FeatureFactory.getFactory(activity) 202 .getPanelFeatureProvider() 203 .getPanel(activity, arguments); 204 205 if (mPanel == null) { 206 activity.finish(); 207 } 208 209 mPanel.registerCallback(new LocalPanelCallback()); 210 if (mPanel instanceof LifecycleObserver) { 211 getLifecycle().addObserver((LifecycleObserver) mPanel); 212 } 213 214 mMetricsProvider = FeatureFactory.getFactory(activity).getMetricsFeatureProvider(); 215 216 mPanelSlices.setLayoutManager(new LinearLayoutManager((activity))); 217 // Add predraw listener to remove the animation and while we wait for Slices to load. 218 mLayoutView.getViewTreeObserver().addOnPreDrawListener(mOnPreDrawListener); 219 220 // Start loading Slices. When finished, the Panel will animate in. 221 loadAllSlices(); 222 223 final IconCompat icon = mPanel.getIcon(); 224 final CharSequence title = mPanel.getTitle(); 225 if (icon == null) { 226 mTitleView.setVisibility(View.VISIBLE); 227 mPanelHeader.setVisibility(View.GONE); 228 mTitleView.setText(title); 229 } else { 230 mTitleView.setVisibility(View.GONE); 231 mPanelHeader.setVisibility(View.VISIBLE); 232 mPanelHeader.setAccessibilityPaneTitle(title); 233 mTitleIcon.setImageIcon(icon.toIcon(getContext())); 234 mHeaderTitle.setText(title); 235 mHeaderSubtitle.setText(mPanel.getSubTitle()); 236 if (mPanel.getHeaderIconIntent() != null) { 237 mTitleIcon.setOnClickListener(getHeaderIconListener()); 238 mTitleIcon.setLayoutParams(new LinearLayout.LayoutParams( 239 ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)); 240 } else { 241 final int size = getResources().getDimensionPixelSize( 242 R.dimen.output_switcher_panel_icon_size); 243 mTitleIcon.setLayoutParams(new LinearLayout.LayoutParams(size, size)); 244 } 245 } 246 247 if (mPanel.getViewType() == PanelContent.VIEW_TYPE_SLIDER_LARGE_ICON) { 248 mFooterDivider.setVisibility(View.VISIBLE); 249 } else { 250 mFooterDivider.setVisibility(View.GONE); 251 } 252 253 mSeeMoreButton.setOnClickListener(getSeeMoreListener()); 254 mDoneButton.setOnClickListener(getCloseListener()); 255 256 if (mPanel.isCustomizedButtonUsed()) { 257 final CharSequence customTitle = mPanel.getCustomizedButtonTitle(); 258 if (TextUtils.isEmpty(customTitle)) { 259 mSeeMoreButton.setVisibility(View.GONE); 260 } else { 261 mSeeMoreButton.setVisibility(View.VISIBLE); 262 mSeeMoreButton.setText(customTitle); 263 } 264 } else if (mPanel.getSeeMoreIntent() == null) { 265 // If getSeeMoreIntent() is null hide the mSeeMoreButton. 266 mSeeMoreButton.setVisibility(View.GONE); 267 } 268 269 // Log panel opened. 270 mMetricsProvider.action( 271 0 /* attribution */, 272 SettingsEnums.PAGE_VISIBLE /* opened panel - Action */, 273 mPanel.getMetricsCategory(), 274 callingPackageName, 275 0 /* value */); 276 } 277 loadAllSlices()278 private void loadAllSlices() { 279 mSliceLiveData.clear(); 280 final List<Uri> sliceUris = mPanel.getSlices(); 281 mPanelSlicesLoaderCountdownLatch = new PanelSlicesLoaderCountdownLatch(sliceUris.size()); 282 283 for (Uri uri : sliceUris) { 284 final LiveData<Slice> sliceLiveData = SliceLiveData.fromUri(getActivity(), uri, 285 (int type, Throwable source)-> { 286 removeSliceLiveData(uri); 287 mPanelSlicesLoaderCountdownLatch.markSliceLoaded(uri); 288 }); 289 290 // Add slice first to make it in order. Will remove it later if there's an error. 291 mSliceLiveData.put(uri, sliceLiveData); 292 293 sliceLiveData.observe(getViewLifecycleOwner(), slice -> { 294 // If the Slice has already loaded, do nothing. 295 if (mPanelSlicesLoaderCountdownLatch.isSliceLoaded(uri)) { 296 return; 297 } 298 299 /** 300 * Watching for the {@link Slice} to load. 301 * <p> 302 * If the Slice comes back {@code null} or with the Error attribute, if slice 303 * uri is not in the whitelist, remove the Slice data from the list, otherwise 304 * keep the Slice data. 305 * <p> 306 * If the Slice has come back fully loaded, then mark the Slice as loaded. No 307 * other actions required since we already have the Slice data in the list. 308 * <p> 309 * If the Slice does not match the above condition, we will still want to mark 310 * it as loaded after 250ms timeout to avoid delay showing up the panel for 311 * too long. Since we are still having the Slice data in the list, the Slice 312 * will show up later once it is loaded. 313 */ 314 final SliceMetadata metadata = SliceMetadata.from(getActivity(), slice); 315 if (slice == null || metadata.isErrorSlice()) { 316 removeSliceLiveData(uri); 317 mPanelSlicesLoaderCountdownLatch.markSliceLoaded(uri); 318 } else if (metadata.getLoadingState() == SliceMetadata.LOADED_ALL) { 319 mPanelSlicesLoaderCountdownLatch.markSliceLoaded(uri); 320 } else { 321 Handler handler = new Handler(); 322 handler.postDelayed(() -> { 323 mPanelSlicesLoaderCountdownLatch.markSliceLoaded(uri); 324 loadPanelWhenReady(); 325 }, DURATION_SLICE_BINDING_TIMEOUT_MS); 326 } 327 328 loadPanelWhenReady(); 329 }); 330 } 331 } 332 removeSliceLiveData(Uri uri)333 private void removeSliceLiveData(Uri uri) { 334 final List<String> whiteList = Arrays.asList( 335 getResources().getStringArray( 336 R.array.config_panel_keep_observe_uri)); 337 if (!whiteList.contains(uri.toString())) { 338 mSliceLiveData.remove(uri); 339 } 340 } 341 342 /** 343 * When all of the Slices have loaded for the first time, then we can setup the 344 * {@link RecyclerView}. 345 * <p> 346 * When the Recyclerview has been laid out, we can begin the animation with the 347 * {@link mOnGlobalLayoutListener}, which calls {@link #animateIn()}. 348 */ loadPanelWhenReady()349 private void loadPanelWhenReady() { 350 if (mPanelSlicesLoaderCountdownLatch.isPanelReadyToLoad()) { 351 mAdapter = new PanelSlicesAdapter( 352 this, mSliceLiveData, mPanel.getMetricsCategory()); 353 mPanelSlices.setAdapter(mAdapter); 354 mPanelSlices.getViewTreeObserver() 355 .addOnGlobalLayoutListener(mOnGlobalLayoutListener); 356 mPanelSlices.setVisibility(View.VISIBLE); 357 358 final DividerItemDecoration itemDecoration = new DividerItemDecoration(getActivity()); 359 itemDecoration 360 .setDividerCondition(DividerItemDecoration.DIVIDER_CONDITION_BOTH); 361 if (mPanelSlices.getItemDecorationCount() == 0) { 362 mPanelSlices.addItemDecoration(itemDecoration); 363 } 364 } 365 } 366 367 /** 368 * Animate a Panel onto the screen. 369 * <p> 370 * Takes the entire panel and animates in from behind the navigation bar. 371 * <p> 372 * Relies on the Panel being having a fixed height to begin the animation. 373 */ animateIn()374 private void animateIn() { 375 final View panelContent = mLayoutView.findViewById(R.id.panel_container); 376 final AnimatorSet animatorSet = buildAnimatorSet(mLayoutView, 377 panelContent.getHeight() /* startY */, 0.0f /* endY */, 378 0.0f /* startAlpha */, 1.0f /* endAlpha */, 379 DURATION_ANIMATE_PANEL_EXPAND_MS); 380 final ValueAnimator animator = new ValueAnimator(); 381 animator.setFloatValues(0.0f, 1.0f); 382 animatorSet.play(animator); 383 animatorSet.start(); 384 // Remove the predraw listeners on the Panel. 385 mLayoutView.getViewTreeObserver().removeOnPreDrawListener(mOnPreDrawListener); 386 } 387 388 /** 389 * Build an {@link AnimatorSet} to animate the Panel, {@param parentView} in or out of the 390 * screen, based on the positional parameters {@param startY}, {@param endY}, the parameters 391 * for alpha changes {@param startAlpha}, {@param endAlpha}, and the {@param duration} in 392 * milliseconds. 393 */ 394 @NonNull buildAnimatorSet(@onNull View parentView, float startY, float endY, float startAlpha, float endAlpha, int duration)395 private static AnimatorSet buildAnimatorSet(@NonNull View parentView, float startY, float endY, 396 float startAlpha, float endAlpha, int duration) { 397 final View sheet = parentView.findViewById(R.id.panel_container); 398 final AnimatorSet animatorSet = new AnimatorSet(); 399 animatorSet.setDuration(duration); 400 animatorSet.setInterpolator(new DecelerateInterpolator()); 401 animatorSet.playTogether( 402 ObjectAnimator.ofFloat(sheet, View.TRANSLATION_Y, startY, endY), 403 ObjectAnimator.ofFloat(sheet, View.ALPHA, startAlpha, endAlpha)); 404 return animatorSet; 405 } 406 407 @Override onDestroyView()408 public void onDestroyView() { 409 super.onDestroyView(); 410 411 if (TextUtils.isEmpty(mPanelClosedKey)) { 412 mPanelClosedKey = PanelClosedKeys.KEY_OTHERS; 413 } 414 415 if (mLayoutView != null) { 416 mLayoutView.getViewTreeObserver().removeOnGlobalLayoutListener(mPanelLayoutListener); 417 } 418 mMetricsProvider.action( 419 0 /* attribution */, 420 SettingsEnums.PAGE_HIDE, 421 mPanel.getMetricsCategory(), 422 mPanelClosedKey, 423 0 /* value */); 424 } 425 426 @VisibleForTesting getSeeMoreListener()427 View.OnClickListener getSeeMoreListener() { 428 return (v) -> { 429 mPanelClosedKey = PanelClosedKeys.KEY_SEE_MORE; 430 if (mPanel.isCustomizedButtonUsed()) { 431 mPanel.onClickCustomizedButton(); 432 } else { 433 final FragmentActivity activity = getActivity(); 434 activity.startActivityForResult(mPanel.getSeeMoreIntent(), 0); 435 activity.finish(); 436 } 437 }; 438 } 439 440 @VisibleForTesting getCloseListener()441 View.OnClickListener getCloseListener() { 442 return (v) -> { 443 mPanelClosedKey = PanelClosedKeys.KEY_DONE; 444 getActivity().finish(); 445 }; 446 } 447 448 @VisibleForTesting 449 View.OnClickListener getHeaderIconListener() { 450 return (v) -> { 451 final FragmentActivity activity = getActivity(); 452 activity.startActivity(mPanel.getHeaderIconIntent()); 453 }; 454 } 455 456 int getPanelViewType() { 457 return mPanel.getViewType(); 458 } 459 460 class LocalPanelCallback implements PanelContentCallback { 461 462 @Override 463 public void onCustomizedButtonStateChanged() { 464 ThreadUtils.postOnMainThread(() -> { 465 mSeeMoreButton.setVisibility( 466 mPanel.isCustomizedButtonUsed() ? View.VISIBLE : View.GONE); 467 mSeeMoreButton.setText(mPanel.getCustomizedButtonTitle()); 468 }); 469 } 470 471 @Override 472 public void onHeaderChanged() { 473 ThreadUtils.postOnMainThread(() -> { 474 mTitleIcon.setImageIcon(mPanel.getIcon().toIcon(getContext())); 475 mHeaderTitle.setText(mPanel.getTitle()); 476 mHeaderSubtitle.setText(mPanel.getSubTitle()); 477 }); 478 } 479 480 @Override 481 public void forceClose() { 482 mPanelClosedKey = PanelClosedKeys.KEY_OTHERS; 483 getFragmentActivity().finish(); 484 } 485 486 @VisibleForTesting 487 FragmentActivity getFragmentActivity() { 488 return getActivity(); 489 } 490 } 491 } 492