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