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