1 /* 2 * Copyright 2019 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.car.ui.preference; 18 19 import static com.android.car.ui.core.CarUi.MIN_TARGET_API; 20 import static com.android.car.ui.utils.CarUiUtils.requireViewByRefId; 21 22 import android.content.Context; 23 import android.os.Bundle; 24 import android.util.Log; 25 import android.util.Pair; 26 import android.view.LayoutInflater; 27 import android.view.View; 28 import android.view.ViewGroup; 29 30 import androidx.annotation.NonNull; 31 import androidx.annotation.Nullable; 32 import androidx.annotation.RequiresApi; 33 import androidx.fragment.app.DialogFragment; 34 import androidx.fragment.app.Fragment; 35 import androidx.preference.DialogPreference; 36 import androidx.preference.DropDownPreference; 37 import androidx.preference.EditTextPreference; 38 import androidx.preference.ListPreference; 39 import androidx.preference.MultiSelectListPreference; 40 import androidx.preference.Preference; 41 import androidx.preference.PreferenceFragmentCompat; 42 import androidx.preference.PreferenceGroup; 43 import androidx.preference.PreferenceScreen; 44 import androidx.preference.SwitchPreference; 45 import androidx.preference.TwoStatePreference; 46 import androidx.recyclerview.widget.RecyclerView; 47 48 import com.android.car.ui.FocusArea; 49 import com.android.car.ui.R; 50 import com.android.car.ui.baselayout.Insets; 51 import com.android.car.ui.baselayout.InsetsChangedListener; 52 import com.android.car.ui.core.CarUi; 53 import com.android.car.ui.recyclerview.CarUiRecyclerView; 54 import com.android.car.ui.toolbar.Toolbar; 55 import com.android.car.ui.toolbar.ToolbarController; 56 import com.android.car.ui.utils.CarUiUtils; 57 58 import java.util.ArrayDeque; 59 import java.util.ArrayList; 60 import java.util.Arrays; 61 import java.util.Deque; 62 import java.util.HashMap; 63 import java.util.List; 64 import java.util.Map; 65 66 /** 67 * A PreferenceFragmentCompat is the entry point to using the Preference library. 68 * 69 * <p>Using this fragment will replace regular Preferences with CarUi equivalents. Because of this, 70 * certain properties that cannot be read out of Preferences will be lost upon calling 71 * {@link #setPreferenceScreen(PreferenceScreen)}. These include the preference viewId, 72 * defaultValue, and enabled state. 73 */ 74 @SuppressWarnings("AndroidJdkLibsChecker") 75 @RequiresApi(MIN_TARGET_API) 76 public abstract class PreferenceFragment extends PreferenceFragmentCompat implements 77 InsetsChangedListener { 78 79 /** 80 * Only for PreferenceFragment internal usage. Apps shouldn't use this as the 81 * {@link RecyclerView} that's provided here is not the real RecyclerView and has very limited 82 * functionality. 83 */ 84 public interface AndroidxRecyclerViewProvider { 85 86 /** 87 * returns instance of {@link RecyclerView} that proxies PreferenceFragment calls to the 88 * real RecyclerView implementation. 89 */ getRecyclerView()90 RecyclerView getRecyclerView(); 91 } 92 93 private static final String TAG = "CarUiPreferenceFragment"; 94 private static final String DIALOG_FRAGMENT_TAG = 95 "com.android.car.ui.PreferenceFragment.DIALOG"; 96 97 @NonNull 98 private CarUiRecyclerView mCarUiRecyclerView; 99 @Nullable 100 private String mLastSelectedPrefKey; 101 private int mLastFocusedAndSelectedPrefPosition; 102 103 @Override onViewCreated(@onNull View view, @Nullable Bundle savedInstanceState)104 public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { 105 super.onViewCreated(view, savedInstanceState); 106 107 ToolbarController toolbar = getPreferenceToolbar(this); 108 if (toolbar != null) { 109 setupToolbar(toolbar); 110 } 111 } 112 113 /** 114 * Sets up what the toolbar should display when on this PreferenceFragment. 115 * 116 * This can be overridden in subclasses to customize the toolbar. By default it puts a back 117 * button on the toolbar, and sets its title to the {@link PreferenceScreen PreferenceScreen's} 118 * title. 119 * 120 * @param toolbar The toolbar from {@link #getPreferenceToolbar(Fragment)}, where the Fragment 121 * passed to getToolbar() is this fragment. 122 */ setupToolbar(@onNull ToolbarController toolbar)123 protected void setupToolbar(@NonNull ToolbarController toolbar) { 124 toolbar.setNavButtonMode(Toolbar.NavButtonMode.BACK); 125 PreferenceScreen preferenceScreen = getPreferenceScreen(); 126 if (preferenceScreen != null) { 127 toolbar.setTitle(preferenceScreen.getTitle()); 128 } else { 129 toolbar.setTitle(""); 130 } 131 } 132 133 /** 134 * Gets the toolbar for the given fragment. The fragment can be either this PreferenceFragment, 135 * or one of the fragments that are created from it such as {@link ListPreferenceFragment}. 136 * 137 * This can be overridden by subclasses to have the fragments use a different toolbar. 138 * 139 * @see #getPreferenceInsets(Fragment) 140 * @param fragment The fragment to get a toolbar for. Either this fragment, or one of the 141 * fragments that it launches. 142 */ 143 @Nullable getPreferenceToolbar(@onNull Fragment fragment)144 protected ToolbarController getPreferenceToolbar(@NonNull Fragment fragment) { 145 return CarUi.getToolbar(getActivity()); 146 } 147 148 /** 149 * Gets the {@link Insets} for the given fragment. The fragment can be either this 150 * PreferenceFragment, or one of the fragments that are created from it such as 151 * {@link ListPreferenceFragment}. 152 * 153 * This can be overridden by subclasses to have the fragments use different insets. 154 * 155 * @see #getPreferenceToolbar(Fragment) 156 * @param fragment The fragment to get insets for. Either this fragment, or one of the 157 * fragments that it launches. 158 */ 159 @Nullable getPreferenceInsets(@onNull Fragment fragment)160 protected Insets getPreferenceInsets(@NonNull Fragment fragment) { 161 return CarUi.getInsets(getActivity()); 162 } 163 164 @Override onStart()165 public void onStart() { 166 super.onStart(); 167 Insets insets = getPreferenceInsets(this); 168 if (insets != null) { 169 onCarUiInsetsChanged(insets); 170 } 171 } 172 173 @Override onCarUiInsetsChanged(@onNull Insets insets)174 public void onCarUiInsetsChanged(@NonNull Insets insets) { 175 View view = requireView(); 176 FocusArea focusArea = requireViewByRefId(view, R.id.car_ui_focus_area); 177 focusArea.setHighlightPadding(0, insets.getTop(), 0, insets.getBottom()); 178 focusArea.setBoundsOffset(0, insets.getTop(), 0, insets.getBottom()); 179 getCarUiRecyclerView().setPadding(0, insets.getTop(), 0, insets.getBottom()); 180 view.setPadding(insets.getLeft(), 0, insets.getRight(), 0); 181 } 182 183 /** 184 * Called when a preference in the tree requests to display a dialog. Subclasses should override 185 * this method to display custom dialogs or to handle dialogs for custom preference classes. 186 * 187 * <p>Note: this is borrowed as-is from androidx.preference.PreferenceFragmentCompat with 188 * updates to launch Car UI library {@link DialogFragment} instead of the ones in the 189 * support library. 190 * 191 * @param preference The {@link Preference} object requesting the dialog 192 */ 193 @Override onDisplayPreferenceDialog(Preference preference)194 public void onDisplayPreferenceDialog(Preference preference) { 195 196 if (getActivity() instanceof OnPreferenceDisplayDialogCallback 197 && ((OnPreferenceDisplayDialogCallback) getActivity()) 198 .onPreferenceDisplayDialog(this, preference)) { 199 return; 200 } 201 202 // check if dialog is already showing 203 if (requireFragmentManager().findFragmentByTag(DIALOG_FRAGMENT_TAG) != null) { 204 return; 205 } 206 207 final Fragment f; 208 if (preference instanceof EditTextPreference) { 209 f = EditTextPreferenceDialogFragment.newInstance(preference.getKey()); 210 } else if (preference instanceof ListPreference) { 211 f = ListPreferenceFragment.newInstance(preference.getKey()); 212 } else if (preference instanceof MultiSelectListPreference) { 213 f = MultiSelectListPreferenceFragment.newInstance(preference.getKey()); 214 } else if (preference instanceof CarUiSeekBarDialogPreference) { 215 f = SeekbarPreferenceDialogFragment.newInstance(preference.getKey()); 216 } else { 217 throw new IllegalArgumentException( 218 "Cannot display dialog for an unknown Preference type: " 219 + preference.getClass().getSimpleName() 220 + ". Make sure to implement onPreferenceDisplayDialog() to handle " 221 + "displaying a custom dialog for this Preference."); 222 } 223 224 f.setTargetFragment(this, 0); 225 226 if (f instanceof DialogFragment) { 227 ((DialogFragment) f).show(getFragmentManager(), DIALOG_FRAGMENT_TAG); 228 } else { 229 if (getActivity() == null) { 230 throw new IllegalStateException( 231 "Preference fragment is not attached to an Activity."); 232 } 233 234 if (getView() == null) { 235 throw new IllegalStateException( 236 "Preference fragment must have a layout."); 237 } 238 239 Context context = getContext(); 240 getParentFragmentManager().beginTransaction() 241 .setCustomAnimations( 242 CarUiUtils.getAttrResourceId(context, 243 android.R.attr.fragmentOpenEnterAnimation), 244 CarUiUtils.getAttrResourceId(context, 245 android.R.attr.fragmentOpenExitAnimation), 246 CarUiUtils.getAttrResourceId(context, 247 android.R.attr.fragmentCloseEnterAnimation), 248 CarUiUtils.getAttrResourceId(context, 249 android.R.attr.fragmentCloseExitAnimation)) 250 .replace(((ViewGroup) getView().getParent()).getId(), f) 251 .addToBackStack(null) 252 .commit(); 253 } 254 } 255 256 @Override onResume()257 public void onResume() { 258 super.onResume(); 259 if (mLastSelectedPrefKey != null) { 260 scrollToPreference(mLastSelectedPrefKey); 261 } 262 } 263 264 @Override onPreferenceTreeClick(Preference preference)265 public boolean onPreferenceTreeClick(Preference preference) { 266 mLastSelectedPrefKey = preference.getKey(); 267 View focus = getView().findFocus(); 268 mLastFocusedAndSelectedPrefPosition = mCarUiRecyclerView.getChildLayoutPosition(focus); 269 270 return super.onPreferenceTreeClick(preference); 271 } 272 273 /** 274 * This override of setPreferenceScreen replaces preferences with their CarUi versions first. 275 */ 276 @Override setPreferenceScreen(PreferenceScreen preferenceScreen)277 public void setPreferenceScreen(PreferenceScreen preferenceScreen) { 278 // We do a search of the tree and every time we see a PreferenceGroup we remove 279 // all it's children, replace them with CarUi versions, and then re-add them 280 281 Map<Preference, String> dependencies = new HashMap<>(); 282 List<Preference> children = new ArrayList<>(); 283 284 // Stack of preferences to process 285 Deque<Preference> stack = new ArrayDeque<>(); 286 stack.addFirst(preferenceScreen); 287 288 while (!stack.isEmpty()) { 289 Preference preference = stack.removeFirst(); 290 291 if (preference instanceof PreferenceGroup) { 292 PreferenceGroup pg = (PreferenceGroup) preference; 293 294 children.clear(); 295 for (int i = 0; i < pg.getPreferenceCount(); i++) { 296 children.add(pg.getPreference(i)); 297 } 298 299 pg.removeAll(); 300 301 for (Preference child : children) { 302 Preference replacement = getReplacementFor(child); 303 304 dependencies.put(replacement, child.getDependency()); 305 pg.addPreference(replacement); 306 stack.addFirst(replacement); 307 } 308 } 309 } 310 311 super.setPreferenceScreen(preferenceScreen); 312 313 // Set the dependencies after all the swapping has been done and they've been 314 // associated with this fragment, or we could potentially fail to find preferences 315 // or use the wrong preferenceManager 316 for (Map.Entry<Preference, String> entry : dependencies.entrySet()) { 317 entry.getKey().setDependency(entry.getValue()); 318 } 319 } 320 321 /** 322 * In order to change the layout for {@link PreferenceFragment}, make sure the correct layout is 323 * passed to PreferenceFragment.CarUi theme. 324 * Override ht method in order to inflate {@link CarUiRecyclerView} 325 */ 326 @NonNull onCreateCarUiRecyclerView(LayoutInflater inflater, ViewGroup parent, Bundle savedInstanceState)327 public CarUiRecyclerView onCreateCarUiRecyclerView(LayoutInflater inflater, ViewGroup parent, 328 Bundle savedInstanceState) { 329 return requireViewByRefId(parent, R.id.recycler_view); 330 } 331 332 @NonNull getCarUiRecyclerView()333 public CarUiRecyclerView getCarUiRecyclerView() { 334 return mCarUiRecyclerView; 335 } 336 337 // Mapping from regular preferences to CarUi preferences. 338 // Order is important, subclasses must come before their base classes 339 // Make sure all the following classes are added to proguard configuration. 340 private static final List<Pair<Class<? extends Preference>, Class<? extends Preference>>> 341 sPreferenceMapping = Arrays.asList( 342 new Pair<>(DropDownPreference.class, CarUiDropDownPreference.class), 343 new Pair<>(ListPreference.class, CarUiListPreference.class), 344 new Pair<>(MultiSelectListPreference.class, CarUiMultiSelectListPreference.class), 345 new Pair<>(EditTextPreference.class, CarUiEditTextPreference.class), 346 new Pair<>(SwitchPreference.class, CarUiSwitchPreference.class), 347 new Pair<>(Preference.class, CarUiPreference.class) 348 ); 349 350 /** 351 * Gets the CarUi version of the passed in preference. If there is no suitable replacement, this 352 * method will return it's input. 353 * 354 * <p>When given a Preference that extends a replaceable preference, we log a warning instead 355 * of replacing it so that we don't remove any functionality. 356 */ getReplacementFor(Preference preference)357 private static Preference getReplacementFor(Preference preference) { 358 Class<? extends Preference> clazz = preference.getClass(); 359 360 for (Pair<Class<? extends Preference>, Class<? extends Preference>> replacement 361 : sPreferenceMapping) { 362 Class<? extends Preference> source = replacement.first; 363 Class<? extends Preference> target = replacement.second; 364 if (source.isAssignableFrom(clazz)) { 365 if (clazz == source) { 366 try { 367 return copyPreference(preference, (Preference) target 368 .getDeclaredConstructor(Context.class) 369 .newInstance(preference.getContext())); 370 } catch (ReflectiveOperationException e) { 371 throw new RuntimeException(e); 372 } 373 } else if (clazz == target || source == Preference.class) { 374 // Don't warn about subclasses of Preference because there are many legitimate 375 // uses for non-carui Preference subclasses, like Preference groups. 376 return preference; 377 } else { 378 Log.w(TAG, "Subclass of " + source.getSimpleName() + " was used, " 379 + "preventing us from substituting it with " + target.getSimpleName()); 380 return preference; 381 } 382 } 383 } 384 385 return preference; 386 } 387 388 @Override onCreateRecyclerView(LayoutInflater inflater, ViewGroup parent, Bundle savedInstanceState)389 public RecyclerView onCreateRecyclerView(LayoutInflater inflater, ViewGroup parent, 390 Bundle savedInstanceState) { 391 mCarUiRecyclerView = onCreateCarUiRecyclerView(inflater, parent, savedInstanceState); 392 RecyclerView recyclerView = null; 393 if (mCarUiRecyclerView instanceof AndroidxRecyclerViewProvider) { 394 recyclerView = ((AndroidxRecyclerViewProvider) mCarUiRecyclerView).getRecyclerView(); 395 } 396 if (recyclerView == null) { 397 recyclerView = super.onCreateRecyclerView(inflater, parent, savedInstanceState); 398 } 399 400 // When not in touch mode, focus on the previously focused and selected item, if any. 401 if (mCarUiRecyclerView != null) { 402 mCarUiRecyclerView.addOnChildAttachStateChangeListener( 403 new RecyclerView.OnChildAttachStateChangeListener() { 404 @Override 405 public void onChildViewAttachedToWindow(View view) { 406 int position = mCarUiRecyclerView.getChildLayoutPosition(view); 407 if (position == mLastFocusedAndSelectedPrefPosition) { 408 view.requestFocus(); 409 } 410 } 411 @Override 412 public void onChildViewDetachedFromWindow(View view) { 413 } 414 }); 415 } 416 return recyclerView; 417 } 418 419 /** 420 * Copies all the properties of one preference to another. 421 * 422 * @return the {@code to} parameter 423 */ copyPreference(Preference from, Preference to)424 private static Preference copyPreference(Preference from, Preference to) { 425 // viewId and defaultValue don't have getters 426 // isEnabled() is not completely symmetrical with setEnabled(), so we can't use it. 427 to.setTitle(from.getTitle()); 428 to.setOnPreferenceClickListener(from.getOnPreferenceClickListener()); 429 to.setOnPreferenceChangeListener(from.getOnPreferenceChangeListener()); 430 to.setIcon(from.getIcon()); 431 to.setFragment(from.getFragment()); 432 to.setIntent(from.getIntent()); 433 to.setKey(from.getKey()); 434 to.setOrder(from.getOrder()); 435 to.setSelectable(from.isSelectable()); 436 to.setPersistent(from.isPersistent()); 437 to.setIconSpaceReserved(from.isIconSpaceReserved()); 438 to.setWidgetLayoutResource(from.getWidgetLayoutResource()); 439 to.setPreferenceDataStore(from.getPreferenceDataStore()); 440 to.setSingleLineTitle(from.isSingleLineTitle()); 441 to.setVisible(from.isVisible()); 442 to.setLayoutResource(from.getLayoutResource()); 443 to.setCopyingEnabled(from.isCopyingEnabled()); 444 445 if (!(to instanceof UxRestrictablePreference)) { 446 to.setShouldDisableView(from.getShouldDisableView()); 447 } 448 449 if (from.getSummaryProvider() != null) { 450 to.setSummaryProvider(from.getSummaryProvider()); 451 } else { 452 to.setSummary(from.getSummary()); 453 } 454 455 if (from.peekExtras() != null) { 456 to.getExtras().putAll(from.peekExtras()); 457 } 458 459 if (from instanceof DialogPreference) { 460 DialogPreference fromDialog = (DialogPreference) from; 461 DialogPreference toDialog = (DialogPreference) to; 462 toDialog.setDialogTitle(fromDialog.getDialogTitle()); 463 toDialog.setDialogIcon(fromDialog.getDialogIcon()); 464 toDialog.setDialogMessage(fromDialog.getDialogMessage()); 465 toDialog.setDialogLayoutResource(fromDialog.getDialogLayoutResource()); 466 toDialog.setNegativeButtonText(fromDialog.getNegativeButtonText()); 467 toDialog.setPositiveButtonText(fromDialog.getPositiveButtonText()); 468 } 469 470 // DropDownPreference extends ListPreference and doesn't add any extra api surface, 471 // so we don't need a case for it 472 if (from instanceof ListPreference) { 473 ListPreference fromList = (ListPreference) from; 474 ListPreference toList = (ListPreference) to; 475 toList.setEntries(fromList.getEntries()); 476 toList.setEntryValues(fromList.getEntryValues()); 477 toList.setValue(fromList.getValue()); 478 } else if (from instanceof EditTextPreference) { 479 EditTextPreference fromText = (EditTextPreference) from; 480 EditTextPreference toText = (EditTextPreference) to; 481 toText.setText(fromText.getText()); 482 } else if (from instanceof MultiSelectListPreference) { 483 MultiSelectListPreference fromMulti = (MultiSelectListPreference) from; 484 MultiSelectListPreference toMulti = (MultiSelectListPreference) to; 485 toMulti.setEntries(fromMulti.getEntries()); 486 toMulti.setEntryValues(fromMulti.getEntryValues()); 487 toMulti.setValues(fromMulti.getValues()); 488 } else if (from instanceof TwoStatePreference) { 489 TwoStatePreference fromTwoState = (TwoStatePreference) from; 490 TwoStatePreference toTwoState = (TwoStatePreference) to; 491 toTwoState.setSummaryOn(fromTwoState.getSummaryOn()); 492 toTwoState.setSummaryOff(fromTwoState.getSummaryOff()); 493 494 if (from instanceof SwitchPreference) { 495 SwitchPreference fromSwitch = (SwitchPreference) from; 496 SwitchPreference toSwitch = (SwitchPreference) to; 497 toSwitch.setSwitchTextOn(fromSwitch.getSwitchTextOn()); 498 toSwitch.setSwitchTextOff(fromSwitch.getSwitchTextOff()); 499 } 500 } 501 502 // We don't need to add checks for things that we will never replace, 503 // like PreferenceGroup or CheckBoxPreference 504 505 return to; 506 } 507 } 508