summaryrefslogtreecommitdiff
path: root/main/src
diff options
context:
space:
mode:
authorSetup Wizard Team <android-setup-team-eng@google.com>2018-11-20 23:25:32 +0800
committercnchen <cnchen@google.com>2018-12-03 20:08:19 +0800
commita4cf3a6e436b44e394f21f5dc9c460acfd2f2f90 (patch)
treedbee0293f1e18e8b68f2ae08b6480e3dbf3dd054 /main/src
parentb2ae103b9ffaf2e36d9237169f412c08ce7be7ce (diff)
downloadsetupdesign-a4cf3a6e436b44e394f21f5dc9c460acfd2f2f90.tar.gz
Import updated Android Setupdesign Library 222242242
Test: mm PiperOrigin-RevId: 222242242 Change-Id: I8dc8b996a94876a76475f3f035c3e14c1a620f74
Diffstat (limited to 'main/src')
-rw-r--r--main/src/com/google/android/setupdesign/DividerItemDecoration.java227
-rw-r--r--main/src/com/google/android/setupdesign/GlifLayout.java287
-rw-r--r--main/src/com/google/android/setupdesign/GlifListLayout.java148
-rw-r--r--main/src/com/google/android/setupdesign/GlifPatternDrawable.java297
-rw-r--r--main/src/com/google/android/setupdesign/GlifPreferenceLayout.java115
-rw-r--r--main/src/com/google/android/setupdesign/GlifRecyclerLayout.java173
-rw-r--r--main/src/com/google/android/setupdesign/SetupWizardItemsLayout.java46
-rw-r--r--main/src/com/google/android/setupdesign/SetupWizardLayout.java425
-rw-r--r--main/src/com/google/android/setupdesign/SetupWizardListLayout.java153
-rw-r--r--main/src/com/google/android/setupdesign/SetupWizardPreferenceLayout.java114
-rw-r--r--main/src/com/google/android/setupdesign/SetupWizardRecyclerLayout.java173
-rw-r--r--main/src/com/google/android/setupdesign/gesture/ConsecutiveTapsGestureDetector.java109
-rw-r--r--main/src/com/google/android/setupdesign/items/AbstractItem.java66
-rw-r--r--main/src/com/google/android/setupdesign/items/AbstractItemHierarchy.java142
-rw-r--r--main/src/com/google/android/setupdesign/items/ButtonBarItem.java127
-rw-r--r--main/src/com/google/android/setupdesign/items/ButtonItem.java155
-rw-r--r--main/src/com/google/android/setupdesign/items/ExpandableSwitchItem.java165
-rw-r--r--main/src/com/google/android/setupdesign/items/IItem.java44
-rw-r--r--main/src/com/google/android/setupdesign/items/Item.java175
-rw-r--r--main/src/com/google/android/setupdesign/items/ItemAdapter.java152
-rw-r--r--main/src/com/google/android/setupdesign/items/ItemGroup.java307
-rw-r--r--main/src/com/google/android/setupdesign/items/ItemHierarchy.java89
-rw-r--r--main/src/com/google/android/setupdesign/items/ItemInflater.java41
-rw-r--r--main/src/com/google/android/setupdesign/items/ItemViewHolder.java57
-rw-r--r--main/src/com/google/android/setupdesign/items/RecyclerItemAdapter.java247
-rw-r--r--main/src/com/google/android/setupdesign/items/ReflectionInflater.java139
-rw-r--r--main/src/com/google/android/setupdesign/items/SimpleInflater.java190
-rw-r--r--main/src/com/google/android/setupdesign/items/SwitchItem.java124
-rw-r--r--main/src/com/google/android/setupdesign/span/LinkSpan.java143
-rw-r--r--main/src/com/google/android/setupdesign/span/SpanHelper.java38
-rw-r--r--main/src/com/google/android/setupdesign/template/ButtonFooterMixin.java170
-rw-r--r--main/src/com/google/android/setupdesign/template/ColoredHeaderMixin.java70
-rw-r--r--main/src/com/google/android/setupdesign/template/HeaderMixin.java91
-rw-r--r--main/src/com/google/android/setupdesign/template/IconMixin.java107
-rw-r--r--main/src/com/google/android/setupdesign/template/ListMixin.java206
-rw-r--r--main/src/com/google/android/setupdesign/template/ListViewScrollHandlingDelegate.java82
-rw-r--r--main/src/com/google/android/setupdesign/template/NavigationBarMixin.java77
-rw-r--r--main/src/com/google/android/setupdesign/template/ProgressBarMixin.java128
-rw-r--r--main/src/com/google/android/setupdesign/template/RecyclerMixin.java257
-rw-r--r--main/src/com/google/android/setupdesign/template/RecyclerViewScrollHandlingDelegate.java80
-rw-r--r--main/src/com/google/android/setupdesign/template/RequireScrollMixin.java240
-rw-r--r--main/src/com/google/android/setupdesign/template/ScrollViewScrollHandlingDelegate.java76
-rw-r--r--main/src/com/google/android/setupdesign/util/DrawableLayoutDirectionHelper.java99
-rw-r--r--main/src/com/google/android/setupdesign/util/LinkAccessibilityHelper.java326
-rw-r--r--main/src/com/google/android/setupdesign/util/Partner.java202
-rw-r--r--main/src/com/google/android/setupdesign/util/SystemBarHelper.java358
-rw-r--r--main/src/com/google/android/setupdesign/util/ThemeHelper.java128
-rw-r--r--main/src/com/google/android/setupdesign/util/ThemeResolver.java237
-rw-r--r--main/src/com/google/android/setupdesign/view/BottomScrollView.java109
-rw-r--r--main/src/com/google/android/setupdesign/view/ButtonBarLayout.java118
-rw-r--r--main/src/com/google/android/setupdesign/view/CheckableLinearLayout.java85
-rw-r--r--main/src/com/google/android/setupdesign/view/FillContentLayout.java122
-rw-r--r--main/src/com/google/android/setupdesign/view/HeaderRecyclerView.java275
-rw-r--r--main/src/com/google/android/setupdesign/view/Illustration.java227
-rw-r--r--main/src/com/google/android/setupdesign/view/IllustrationVideoView.java352
-rw-r--r--main/src/com/google/android/setupdesign/view/IntrinsicSizeFrameLayout.java92
-rw-r--r--main/src/com/google/android/setupdesign/view/NavigationBar.java142
-rw-r--r--main/src/com/google/android/setupdesign/view/NavigationBarButton.java177
-rw-r--r--main/src/com/google/android/setupdesign/view/RichTextView.java217
-rw-r--r--main/src/com/google/android/setupdesign/view/StickyHeaderListView.java166
-rw-r--r--main/src/com/google/android/setupdesign/view/StickyHeaderRecyclerView.java145
-rw-r--r--main/src/com/google/android/setupdesign/view/StickyHeaderScrollView.java118
-rw-r--r--main/src/com/google/android/setupdesign/view/TouchableMovementMethod.java79
63 files changed, 10026 insertions, 0 deletions
diff --git a/main/src/com/google/android/setupdesign/DividerItemDecoration.java b/main/src/com/google/android/setupdesign/DividerItemDecoration.java
new file mode 100644
index 0000000..5dfe351
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/DividerItemDecoration.java
@@ -0,0 +1,227 @@
+/*
+ * Copyright (C) 2015 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.google.android.setupdesign;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.Canvas;
+import android.graphics.Rect;
+import android.graphics.drawable.Drawable;
+import androidx.annotation.IntDef;
+import androidx.core.view.ViewCompat;
+import androidx.recyclerview.widget.RecyclerView;
+import android.view.View;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+/**
+ * An {@link androidx.recyclerview.widget.RecyclerView.ItemDecoration} for RecyclerView to draw
+ * dividers between items. This ItemDecoration will draw the drawable specified by {@link
+ * #setDivider(android.graphics.drawable.Drawable)} as the divider in between each item by default,
+ * and the behavior of whether the divider is shown can be customized by subclassing {@link
+ * com.google.android.setupdesign.DividerItemDecoration.DividedViewHolder}.
+ *
+ * <p>Modified from v14 PreferenceFragment.DividerDecoration.
+ */
+public class DividerItemDecoration extends RecyclerView.ItemDecoration {
+
+ /* static section */
+
+ /**
+ * An interface to be implemented by a {@link RecyclerView.ViewHolder} which controls whether
+ * dividers should be shown above and below that item.
+ */
+ public interface DividedViewHolder {
+
+ /**
+ * Returns whether divider is allowed above this item. A divider will be shown only if both
+ * items immediately above and below it allows this divider.
+ */
+ boolean isDividerAllowedAbove();
+
+ /**
+ * Returns whether divider is allowed below this item. A divider will be shown only if both
+ * items immediately above and below it allows this divider.
+ */
+ boolean isDividerAllowedBelow();
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({DIVIDER_CONDITION_EITHER, DIVIDER_CONDITION_BOTH})
+ public @interface DividerCondition {}
+
+ public static final int DIVIDER_CONDITION_EITHER = 0;
+ public static final int DIVIDER_CONDITION_BOTH = 1;
+
+ /** @deprecated Use {@link #DividerItemDecoration(android.content.Context)} */
+ @Deprecated
+ public static DividerItemDecoration getDefault(Context context) {
+ return new DividerItemDecoration(context);
+ }
+
+ /* non-static section */
+
+ private Drawable divider;
+ private int dividerHeight;
+ private int dividerIntrinsicHeight;
+ @DividerCondition private int dividerCondition;
+
+ public DividerItemDecoration() {}
+
+ public DividerItemDecoration(Context context) {
+ final TypedArray a = context.obtainStyledAttributes(R.styleable.SuwDividerItemDecoration);
+ final Drawable divider =
+ a.getDrawable(R.styleable.SuwDividerItemDecoration_android_listDivider);
+ final int dividerHeight =
+ a.getDimensionPixelSize(R.styleable.SuwDividerItemDecoration_android_dividerHeight, 0);
+ @DividerCondition
+ final int dividerCondition =
+ a.getInt(
+ R.styleable.SuwDividerItemDecoration_suwDividerCondition, DIVIDER_CONDITION_EITHER);
+ a.recycle();
+
+ setDivider(divider);
+ setDividerHeight(dividerHeight);
+ setDividerCondition(dividerCondition);
+ }
+
+ @Override
+ public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
+ if (divider == null) {
+ return;
+ }
+ final int childCount = parent.getChildCount();
+ final int width = parent.getWidth();
+ final int dividerHeight = this.dividerHeight != 0 ? this.dividerHeight : dividerIntrinsicHeight;
+ for (int childViewIndex = 0; childViewIndex < childCount; childViewIndex++) {
+ final View view = parent.getChildAt(childViewIndex);
+ if (shouldDrawDividerBelow(view, parent)) {
+ final int top = (int) ViewCompat.getY(view) + view.getHeight();
+ divider.setBounds(0, top, width, top + dividerHeight);
+ divider.draw(c);
+ }
+ }
+ }
+
+ @Override
+ public void getItemOffsets(
+ Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
+ if (shouldDrawDividerBelow(view, parent)) {
+ outRect.bottom = dividerHeight != 0 ? dividerHeight : dividerIntrinsicHeight;
+ }
+ }
+
+ private boolean shouldDrawDividerBelow(View view, RecyclerView parent) {
+ final RecyclerView.ViewHolder holder = parent.getChildViewHolder(view);
+ final int index = holder.getLayoutPosition();
+ final int lastItemIndex = parent.getAdapter().getItemCount() - 1;
+ if (isDividerAllowedBelow(holder)) {
+ if (dividerCondition == DIVIDER_CONDITION_EITHER) {
+ // Draw the divider without consulting the next item if we only
+ // need permission for either above or below.
+ return true;
+ }
+ } else if (dividerCondition == DIVIDER_CONDITION_BOTH || index == lastItemIndex) {
+ // Don't draw if the current view holder doesn't allow drawing below
+ // and the current theme requires permission for both the item below and above.
+ // Also, if this is the last item, there is no item below to ask permission
+ // for whether to draw a divider above, so don't draw it.
+ return false;
+ }
+ // Require permission from index below to draw the divider.
+ if (index < lastItemIndex) {
+ final RecyclerView.ViewHolder nextHolder = parent.findViewHolderForLayoutPosition(index + 1);
+ if (!isDividerAllowedAbove(nextHolder)) {
+ // Don't draw if the next view holder doesn't allow drawing above
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Whether a divider is allowed above the view holder. The allowed values will be combined
+ * according to {@link #getDividerCondition()}. The default implementation delegates to {@link
+ * com.google.android.setupdesign.DividerItemDecoration.DividedViewHolder}, or simply allows the
+ * divider if the view holder doesn't implement {@code DividedViewHolder}. Subclasses can override
+ * this to give more information to decide whether a divider should be drawn.
+ *
+ * @return True if divider is allowed above this view holder.
+ */
+ protected boolean isDividerAllowedAbove(RecyclerView.ViewHolder viewHolder) {
+ return !(viewHolder instanceof DividedViewHolder)
+ || ((DividedViewHolder) viewHolder).isDividerAllowedAbove();
+ }
+
+ /**
+ * Whether a divider is allowed below the view holder. The allowed values will be combined
+ * according to {@link #getDividerCondition()}. The default implementation delegates to {@link
+ * com.google.android.setupdesign.DividerItemDecoration.DividedViewHolder}, or simply allows the
+ * divider if the view holder doesn't implement {@code DividedViewHolder}. Subclasses can override
+ * this to give more information to decide whether a divider should be drawn.
+ *
+ * @return True if divider is allowed below this view holder.
+ */
+ protected boolean isDividerAllowedBelow(RecyclerView.ViewHolder viewHolder) {
+ return !(viewHolder instanceof DividedViewHolder)
+ || ((DividedViewHolder) viewHolder).isDividerAllowedBelow();
+ }
+
+ /** Sets the drawable to be used as the divider. */
+ public void setDivider(Drawable divider) {
+ if (divider != null) {
+ dividerIntrinsicHeight = divider.getIntrinsicHeight();
+ } else {
+ dividerIntrinsicHeight = 0;
+ }
+ this.divider = divider;
+ }
+
+ /** Gets the drawable currently used as the divider. */
+ public Drawable getDivider() {
+ return divider;
+ }
+
+ /** Sets the divider height, in pixels. */
+ public void setDividerHeight(int dividerHeight) {
+ this.dividerHeight = dividerHeight;
+ }
+
+ /** Gets the divider height, in pixels. */
+ public int getDividerHeight() {
+ return dividerHeight;
+ }
+
+ /**
+ * Sets whether the divider needs permission from both the item view holder below and above from
+ * where the divider would draw itself or just needs permission from one or the other before
+ * drawing itself.
+ */
+ public void setDividerCondition(@DividerCondition int dividerCondition) {
+ this.dividerCondition = dividerCondition;
+ }
+
+ /**
+ * Gets whether the divider needs permission from both the item view holder below and above from
+ * where the divider would draw itself or just needs permission from one or the other before
+ * drawing itself.
+ */
+ @DividerCondition
+ public int getDividerCondition() {
+ return dividerCondition;
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/GlifLayout.java b/main/src/com/google/android/setupdesign/GlifLayout.java
new file mode 100644
index 0000000..0cc7902
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/GlifLayout.java
@@ -0,0 +1,287 @@
+/*
+ * Copyright (C) 2015 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.google.android.setupdesign;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.content.res.ColorStateList;
+import android.content.res.TypedArray;
+import android.graphics.drawable.ColorDrawable;
+import android.graphics.drawable.Drawable;
+import android.os.Build.VERSION_CODES;
+import androidx.annotation.LayoutRes;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewStub;
+import android.widget.ProgressBar;
+import android.widget.ScrollView;
+import android.widget.TextView;
+import com.google.android.setupcompat.PartnerCustomizationLayout;
+import com.google.android.setupcompat.template.StatusBarMixin;
+import com.google.android.setupdesign.template.ButtonFooterMixin;
+import com.google.android.setupdesign.template.ColoredHeaderMixin;
+import com.google.android.setupdesign.template.HeaderMixin;
+import com.google.android.setupdesign.template.IconMixin;
+import com.google.android.setupdesign.template.ProgressBarMixin;
+import com.google.android.setupdesign.template.RequireScrollMixin;
+import com.google.android.setupdesign.template.ScrollViewScrollHandlingDelegate;
+
+/**
+ * Layout for the GLIF theme used in Setup Wizard for N.
+ *
+ * <p>Example usage:
+ *
+ * <pre>{@code
+ * &lt;com.google.android.setupdesign.GlifLayout
+ * xmlns:android="http://schemas.android.com/apk/res/android"
+ * xmlns:app="http://schemas.android.com/apk/res-auto"
+ * android:layout_width="match_parent"
+ * android:layout_height="match_parent"
+ * android:icon="@drawable/my_icon"
+ * app:suwHeaderText="@string/my_title">
+ *
+ * &lt;!-- Content here -->
+ *
+ * &lt;/com.google.android.setupdesign.GlifLayout>
+ * }</pre>
+ */
+public class GlifLayout extends PartnerCustomizationLayout {
+
+ private static final String TAG = "GlifLayout";
+
+ private ColorStateList primaryColor;
+
+ private boolean backgroundPatterned = true;
+
+ /** The color of the background. If null, the color will inherit from primaryColor. */
+ @Nullable private ColorStateList backgroundBaseColor;
+
+ public GlifLayout(Context context) {
+ this(context, 0, 0);
+ }
+
+ public GlifLayout(Context context, int template) {
+ this(context, template, 0);
+ }
+
+ public GlifLayout(Context context, int template, int containerId) {
+ super(context, template, containerId);
+ init(null, R.attr.suwLayoutTheme);
+ }
+
+ public GlifLayout(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init(attrs, R.attr.suwLayoutTheme);
+ }
+
+ @TargetApi(VERSION_CODES.HONEYCOMB)
+ public GlifLayout(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ init(attrs, defStyleAttr);
+ }
+
+ // All the constructors delegate to this init method. The 3-argument constructor is not
+ // available in LinearLayout before v11, so call super with the exact same arguments.
+ private void init(AttributeSet attrs, int defStyleAttr) {
+ registerMixin(HeaderMixin.class, new ColoredHeaderMixin(this, attrs, defStyleAttr));
+ registerMixin(IconMixin.class, new IconMixin(this, attrs, defStyleAttr));
+ registerMixin(ProgressBarMixin.class, new ProgressBarMixin(this));
+ registerMixin(ButtonFooterMixin.class, new ButtonFooterMixin(this));
+ final RequireScrollMixin requireScrollMixin = new RequireScrollMixin(this);
+ registerMixin(RequireScrollMixin.class, requireScrollMixin);
+
+ final ScrollView scrollView = getScrollView();
+ if (scrollView != null) {
+ requireScrollMixin.setScrollHandlingDelegate(
+ new ScrollViewScrollHandlingDelegate(requireScrollMixin, scrollView));
+ }
+
+ TypedArray a =
+ getContext().obtainStyledAttributes(attrs, R.styleable.SuwGlifLayout, defStyleAttr, 0);
+
+ ColorStateList primaryColor = a.getColorStateList(R.styleable.SuwGlifLayout_suwColorPrimary);
+ if (primaryColor != null) {
+ setPrimaryColor(primaryColor);
+ }
+
+ ColorStateList backgroundColor =
+ a.getColorStateList(R.styleable.SuwGlifLayout_suwBackgroundBaseColor);
+ setBackgroundBaseColor(backgroundColor);
+
+ boolean backgroundPatterned =
+ a.getBoolean(R.styleable.SuwGlifLayout_suwBackgroundPatterned, true);
+ setBackgroundPatterned(backgroundPatterned);
+
+ final int stickyHeader = a.getResourceId(R.styleable.SuwGlifLayout_suwStickyHeader, 0);
+ if (stickyHeader != 0) {
+ inflateStickyHeader(stickyHeader);
+ }
+
+ }
+
+ @Override
+ protected View onInflateTemplate(LayoutInflater inflater, @LayoutRes int template) {
+ if (template == 0) {
+ template = R.layout.suw_glif_template;
+ }
+ return inflateTemplate(inflater, R.style.SuwThemeGlif_Light, template);
+ }
+
+ @Override
+ protected ViewGroup findContainer(int containerId) {
+ if (containerId == 0) {
+ containerId = R.id.suw_layout_content;
+ }
+ return super.findContainer(containerId);
+ }
+
+ /**
+ * Sets the sticky header (i.e. header that doesn't scroll) of the layout, which is at the top of
+ * the content area outside of the scrolling container. The header can only be inflated once per
+ * instance of this layout.
+ *
+ * @param header The layout to be inflated as the header.
+ * @return The root of the inflated header view.
+ */
+ public View inflateStickyHeader(@LayoutRes int header) {
+ ViewStub stickyHeaderStub = findManagedViewById(R.id.suw_layout_sticky_header);
+ stickyHeaderStub.setLayoutResource(header);
+ return stickyHeaderStub.inflate();
+ }
+
+ public ScrollView getScrollView() {
+ final View view = findManagedViewById(R.id.suw_scroll_view);
+ return view instanceof ScrollView ? (ScrollView) view : null;
+ }
+
+ public TextView getHeaderTextView() {
+ return getMixin(HeaderMixin.class).getTextView();
+ }
+
+ public void setHeaderText(int title) {
+ getMixin(HeaderMixin.class).setText(title);
+ }
+
+ public void setHeaderText(CharSequence title) {
+ getMixin(HeaderMixin.class).setText(title);
+ }
+
+ public CharSequence getHeaderText() {
+ return getMixin(HeaderMixin.class).getText();
+ }
+
+ public void setHeaderColor(ColorStateList color) {
+ final ColoredHeaderMixin mixin = (ColoredHeaderMixin) getMixin(HeaderMixin.class);
+ mixin.setColor(color);
+ }
+
+ public ColorStateList getHeaderColor() {
+ final ColoredHeaderMixin mixin = (ColoredHeaderMixin) getMixin(HeaderMixin.class);
+ return mixin.getColor();
+ }
+
+ public void setIcon(Drawable icon) {
+ getMixin(IconMixin.class).setIcon(icon);
+ }
+
+ public Drawable getIcon() {
+ return getMixin(IconMixin.class).getIcon();
+ }
+
+ /**
+ * Sets the primary color of this layout, which will be used to determine the color of the
+ * progress bar and the background pattern.
+ */
+ public void setPrimaryColor(@NonNull ColorStateList color) {
+ primaryColor = color;
+ updateBackground();
+ getMixin(ProgressBarMixin.class).setColor(color);
+ }
+
+ public ColorStateList getPrimaryColor() {
+ return primaryColor;
+ }
+
+ /**
+ * Sets the base color of the background view, which is the status bar for phones and the full-
+ * screen background for tablets. If {@link #isBackgroundPatterned()} is true, the pattern will be
+ * drawn with this color.
+ *
+ * @param color The color to use as the base color of the background. If {@code null}, {@link
+ * #getPrimaryColor()} will be used.
+ */
+ public void setBackgroundBaseColor(@Nullable ColorStateList color) {
+ backgroundBaseColor = color;
+ updateBackground();
+ }
+
+ /**
+ * @return The base color of the background. {@code null} indicates the background will be drawn
+ * with {@link #getPrimaryColor()}.
+ */
+ @Nullable
+ public ColorStateList getBackgroundBaseColor() {
+ return backgroundBaseColor;
+ }
+
+ /**
+ * Sets whether the background should be {@link GlifPatternDrawable}. If {@code false}, the
+ * background will be a solid color.
+ */
+ public void setBackgroundPatterned(boolean patterned) {
+ backgroundPatterned = patterned;
+ updateBackground();
+ }
+
+ /** @return True if this view uses {@link GlifPatternDrawable} as background. */
+ public boolean isBackgroundPatterned() {
+ return backgroundPatterned;
+ }
+
+ private void updateBackground() {
+ final View patternBg = findManagedViewById(R.id.suc_layout_status);
+ if (patternBg != null) {
+ int backgroundColor = 0;
+ if (backgroundBaseColor != null) {
+ backgroundColor = backgroundBaseColor.getDefaultColor();
+ } else if (primaryColor != null) {
+ backgroundColor = primaryColor.getDefaultColor();
+ }
+ Drawable background =
+ backgroundPatterned
+ ? new GlifPatternDrawable(backgroundColor)
+ : new ColorDrawable(backgroundColor);
+ getMixin(StatusBarMixin.class).setStatusBarBackground(background);
+ }
+ }
+
+ public boolean isProgressBarShown() {
+ return getMixin(ProgressBarMixin.class).isShown();
+ }
+
+ public void setProgressBarShown(boolean shown) {
+ getMixin(ProgressBarMixin.class).setShown(shown);
+ }
+
+ public ProgressBar peekProgressBar() {
+ return getMixin(ProgressBarMixin.class).peekProgressBar();
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/GlifListLayout.java b/main/src/com/google/android/setupdesign/GlifListLayout.java
new file mode 100644
index 0000000..2232476
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/GlifListLayout.java
@@ -0,0 +1,148 @@
+/*
+ * Copyright (C) 2015 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.google.android.setupdesign;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.os.Build.VERSION_CODES;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ListAdapter;
+import android.widget.ListView;
+import com.google.android.setupdesign.template.ListMixin;
+import com.google.android.setupdesign.template.ListViewScrollHandlingDelegate;
+import com.google.android.setupdesign.template.RequireScrollMixin;
+
+/**
+ * A GLIF themed layout with a ListView. {@code android:entries} can also be used to specify an
+ * {@link com.google.android.setupdesign.items.ItemHierarchy} to be used with this layout in XML.
+ */
+public class GlifListLayout extends GlifLayout {
+
+ private ListMixin listMixin;
+
+ public GlifListLayout(Context context) {
+ this(context, 0, 0);
+ }
+
+ public GlifListLayout(Context context, int template) {
+ this(context, template, 0);
+ }
+
+ public GlifListLayout(Context context, int template, int containerId) {
+ super(context, template, containerId);
+ init(null, 0);
+ }
+
+ public GlifListLayout(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init(attrs, 0);
+ }
+
+ @TargetApi(VERSION_CODES.HONEYCOMB)
+ public GlifListLayout(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ init(attrs, defStyleAttr);
+ }
+
+ private void init(AttributeSet attrs, int defStyleAttr) {
+ listMixin = new ListMixin(this, attrs, defStyleAttr);
+ registerMixin(ListMixin.class, listMixin);
+
+ final RequireScrollMixin requireScrollMixin = getMixin(RequireScrollMixin.class);
+ requireScrollMixin.setScrollHandlingDelegate(
+ new ListViewScrollHandlingDelegate(requireScrollMixin, getListView()));
+ }
+
+ @Override
+ protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
+ super.onLayout(changed, left, top, right, bottom);
+ listMixin.onLayout();
+ }
+
+ @Override
+ protected View onInflateTemplate(LayoutInflater inflater, int template) {
+ if (template == 0) {
+ template = R.layout.suw_glif_list_template;
+ }
+ return super.onInflateTemplate(inflater, template);
+ }
+
+ @Override
+ protected ViewGroup findContainer(int containerId) {
+ if (containerId == 0) {
+ containerId = android.R.id.list;
+ }
+ return super.findContainer(containerId);
+ }
+
+ public ListView getListView() {
+ return listMixin.getListView();
+ }
+
+ public void setAdapter(ListAdapter adapter) {
+ listMixin.setAdapter(adapter);
+ }
+
+ public ListAdapter getAdapter() {
+ return listMixin.getAdapter();
+ }
+
+ /** @deprecated Use {@link #setDividerInsets(int, int)} instead. */
+ @Deprecated
+ public void setDividerInset(int inset) {
+ listMixin.setDividerInset(inset);
+ }
+
+ /**
+ * Sets the start inset of the divider. This will use the default divider drawable set in the
+ * theme and apply insets to it.
+ *
+ * @param start The number of pixels to inset on the "start" side of the list divider. Typically
+ * this will be either {@code @dimen/suw_items_glif_icon_divider_inset} or
+ * {@code @dimen/suw_items_glif_text_divider_inset}.
+ * @param end The number of pixels to inset on the "end" side of the list divider.
+ * @see ListMixin#setDividerInsets(int, int)
+ */
+ public void setDividerInsets(int start, int end) {
+ listMixin.setDividerInsets(start, end);
+ }
+
+ /** @deprecated Use {@link #getDividerInsetStart()} instead. */
+ @Deprecated
+ public int getDividerInset() {
+ return listMixin.getDividerInset();
+ }
+
+ /** @see ListMixin#getDividerInsetStart() */
+ public int getDividerInsetStart() {
+ return listMixin.getDividerInsetStart();
+ }
+
+ /** @see ListMixin#getDividerInsetEnd() */
+ public int getDividerInsetEnd() {
+ return listMixin.getDividerInsetEnd();
+ }
+
+ /** @see ListMixin#getDivider() */
+ public Drawable getDivider() {
+ return listMixin.getDivider();
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/GlifPatternDrawable.java b/main/src/com/google/android/setupdesign/GlifPatternDrawable.java
new file mode 100644
index 0000000..2bc0373
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/GlifPatternDrawable.java
@@ -0,0 +1,297 @@
+/*
+ * Copyright (C) 2015 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.google.android.setupdesign;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.ColorFilter;
+import android.graphics.Paint;
+import android.graphics.Path;
+import android.graphics.PixelFormat;
+import android.graphics.PorterDuff;
+import android.graphics.PorterDuffXfermode;
+import android.graphics.Rect;
+import android.graphics.drawable.Drawable;
+import android.os.Build;
+import androidx.annotation.NonNull;
+import androidx.annotation.VisibleForTesting;
+import java.lang.ref.SoftReference;
+
+/**
+ * This class draws the GLIF pattern used as the status bar background for phones and background for
+ * tablets in GLIF layout.
+ */
+public class GlifPatternDrawable extends Drawable {
+ /*
+ * This class essentially implements a simple SVG in Java code, with some special handling of
+ * scaling when given different bounds.
+ */
+
+ /* static section */
+
+ @SuppressLint("InlinedApi")
+ private static final int[] ATTRS_PRIMARY_COLOR = new int[] {android.R.attr.colorPrimary};
+
+ private static final float VIEWBOX_HEIGHT = 768f;
+ private static final float VIEWBOX_WIDTH = 1366f;
+ // X coordinate of scale focus, as a fraction of the width. (Range is 0 - 1)
+ private static final float SCALE_FOCUS_X = .146f;
+ // Y coordinate of scale focus, as a fraction of the height. (Range is 0 - 1)
+ private static final float SCALE_FOCUS_Y = .228f;
+
+ // Alpha component of the color to be drawn, on top of the grayscale pattern. (Range is 0 - 1)
+ private static final float COLOR_ALPHA = .8f;
+ // Int version of COLOR_ALPHA. (Range is 0 - 255)
+ private static final int COLOR_ALPHA_INT = (int) (COLOR_ALPHA * 255);
+
+ // Cap the bitmap size, such that it won't hurt the performance too much
+ // and it won't crash due to a very large scale.
+ // The drawable will look blurry above this size.
+ // This is a multiplier applied on top of the viewbox size.
+ // Resulting max cache size = (1.5 x 1366, 1.5 x 768) = (2049, 1152)
+ private static final float MAX_CACHED_BITMAP_SCALE = 1.5f;
+
+ private static final int NUM_PATHS = 7;
+
+ private static SoftReference<Bitmap> bitmapCache;
+ private static Path[] patternPaths;
+ private static int[] patternLightness;
+
+ public static GlifPatternDrawable getDefault(Context context) {
+ int colorPrimary = 0;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ final TypedArray a = context.obtainStyledAttributes(ATTRS_PRIMARY_COLOR);
+ colorPrimary = a.getColor(0, Color.BLACK);
+ a.recycle();
+ }
+ return new GlifPatternDrawable(colorPrimary);
+ }
+
+ @VisibleForTesting
+ public static void invalidatePattern() {
+ bitmapCache = null;
+ }
+
+ /* non-static section */
+
+ private int color;
+ private final Paint tempPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+
+ public GlifPatternDrawable(int color) {
+ setColor(color);
+ }
+
+ @Override
+ public void draw(@NonNull Canvas canvas) {
+ final Rect bounds = getBounds();
+ int drawableWidth = bounds.width();
+ int drawableHeight = bounds.height();
+ Bitmap bitmap = null;
+ if (bitmapCache != null) {
+ bitmap = bitmapCache.get();
+ }
+ if (bitmap != null) {
+ final int bitmapWidth = bitmap.getWidth();
+ final int bitmapHeight = bitmap.getHeight();
+ // Invalidate the cache if this drawable is bigger and we can still create a bigger
+ // cache.
+ if (drawableWidth > bitmapWidth && bitmapWidth < VIEWBOX_WIDTH * MAX_CACHED_BITMAP_SCALE) {
+ bitmap = null;
+ } else if (drawableHeight > bitmapHeight
+ && bitmapHeight < VIEWBOX_HEIGHT * MAX_CACHED_BITMAP_SCALE) {
+ bitmap = null;
+ }
+ }
+
+ if (bitmap == null) {
+ // Reset the paint so it can be used to draw the paths in renderOnCanvas
+ tempPaint.reset();
+
+ bitmap = createBitmapCache(drawableWidth, drawableHeight);
+ bitmapCache = new SoftReference<>(bitmap);
+
+ // Reset the paint to so it can be used to draw the bitmap
+ tempPaint.reset();
+ }
+
+ canvas.save();
+ canvas.clipRect(bounds);
+
+ scaleCanvasToBounds(canvas, bitmap, bounds);
+ canvas.drawColor(Color.BLACK);
+ tempPaint.setColor(Color.WHITE);
+ canvas.drawBitmap(bitmap, 0, 0, tempPaint);
+ canvas.drawColor(color);
+
+ canvas.restore();
+ }
+
+ @VisibleForTesting
+ public Bitmap createBitmapCache(int drawableWidth, int drawableHeight) {
+ float scaleX = drawableWidth / VIEWBOX_WIDTH;
+ float scaleY = drawableHeight / VIEWBOX_HEIGHT;
+ float scale = Math.max(scaleX, scaleY);
+ scale = Math.min(MAX_CACHED_BITMAP_SCALE, scale);
+
+ int scaledWidth = (int) (VIEWBOX_WIDTH * scale);
+ int scaledHeight = (int) (VIEWBOX_HEIGHT * scale);
+
+ // Use ALPHA_8 mask to save memory, since the pattern is grayscale only anyway.
+ Bitmap bitmap = Bitmap.createBitmap(scaledWidth, scaledHeight, Bitmap.Config.ALPHA_8);
+ Canvas bitmapCanvas = new Canvas(bitmap);
+ renderOnCanvas(bitmapCanvas, scale);
+ return bitmap;
+ }
+
+ private void renderOnCanvas(Canvas canvas, float scale) {
+ canvas.save();
+ canvas.scale(scale, scale);
+
+ tempPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC));
+
+ // Draw the pattern by creating the paths, adjusting the colors and drawing them. The path
+ // values are extracted from the SVG of the pattern file.
+
+ if (patternPaths == null) {
+ patternPaths = new Path[NUM_PATHS];
+ // Lightness values of the pattern, range 0 - 255
+ patternLightness = new int[] {10, 40, 51, 66, 91, 112, 130};
+
+ Path p = patternPaths[0] = new Path();
+ p.moveTo(1029.4f, 357.5f);
+ p.lineTo(1366f, 759.1f);
+ p.lineTo(1366f, 0f);
+ p.lineTo(1137.7f, 0f);
+ p.close();
+
+ p = patternPaths[1] = new Path();
+ p.moveTo(1138.1f, 0f);
+ p.rLineTo(-144.8f, 768f);
+ p.rLineTo(372.7f, 0f);
+ p.rLineTo(0f, -524f);
+ p.cubicTo(1290.7f, 121.6f, 1219.2f, 41.1f, 1178.7f, 0f);
+ p.close();
+
+ p = patternPaths[2] = new Path();
+ p.moveTo(949.8f, 768f);
+ p.rCubicTo(92.6f, -170.6f, 213f, -440.3f, 269.4f, -768f);
+ p.lineTo(585f, 0f);
+ p.rLineTo(2.1f, 766f);
+ p.close();
+
+ p = patternPaths[3] = new Path();
+ p.moveTo(471.1f, 768f);
+ p.rMoveTo(704.5f, 0f);
+ p.cubicTo(1123.6f, 563.3f, 1027.4f, 275.2f, 856.2f, 0f);
+ p.lineTo(476.4f, 0f);
+ p.rLineTo(-5.3f, 768f);
+ p.close();
+
+ p = patternPaths[4] = new Path();
+ p.moveTo(323.1f, 768f);
+ p.moveTo(777.5f, 768f);
+ p.cubicTo(661.9f, 348.8f, 427.2f, 21.4f, 401.2f, 25.4f);
+ p.lineTo(323.1f, 768f);
+ p.close();
+
+ p = patternPaths[5] = new Path();
+ p.moveTo(178.44286f, 766.8571f);
+ p.lineTo(308.7f, 768f);
+ p.cubicTo(381.7f, 604.6f, 481.6f, 344.3f, 562.2f, 0f);
+ p.lineTo(0f, 0f);
+ p.close();
+
+ p = patternPaths[6] = new Path();
+ p.moveTo(146f, 0f);
+ p.lineTo(0f, 0f);
+ p.lineTo(0f, 768f);
+ p.lineTo(394.2f, 768f);
+ p.cubicTo(327.7f, 475.3f, 228.5f, 201f, 146f, 0f);
+ p.close();
+ }
+
+ for (int i = 0; i < NUM_PATHS; i++) {
+ // Color is 0xAARRGGBB, so alpha << 24 will create a color with (alpha)% black.
+ // Although the color components don't really matter, since the backing bitmap cache is
+ // ALPHA_8.
+ tempPaint.setColor(patternLightness[i] << 24);
+ canvas.drawPath(patternPaths[i], tempPaint);
+ }
+
+ canvas.restore();
+ tempPaint.reset();
+ }
+
+ @VisibleForTesting
+ public void scaleCanvasToBounds(Canvas canvas, Bitmap bitmap, Rect drawableBounds) {
+ int bitmapWidth = bitmap.getWidth();
+ int bitmapHeight = bitmap.getHeight();
+ float scaleX = drawableBounds.width() / (float) bitmapWidth;
+ float scaleY = drawableBounds.height() / (float) bitmapHeight;
+
+ // First scale both sides to fit independently.
+ canvas.scale(scaleX, scaleY);
+ if (scaleY > scaleX) {
+ // Adjust x-scale to maintain aspect ratio using the pivot, so that more of the texture
+ // and less of the blank space on the left edge is seen.
+ canvas.scale(scaleY / scaleX, 1f, SCALE_FOCUS_X * bitmapWidth, 0f);
+ } else if (scaleX > scaleY) {
+ // Adjust y-scale to maintain aspect ratio using the pivot, so that an intersection of
+ // two "circles" can always be seen.
+ canvas.scale(1f, scaleX / scaleY, 0f, SCALE_FOCUS_Y * bitmapHeight);
+ }
+ }
+
+ @Override
+ public void setAlpha(int i) {
+ // Ignore
+ }
+
+ @Override
+ public void setColorFilter(ColorFilter colorFilter) {
+ // Ignore
+ }
+
+ @Override
+ public int getOpacity() {
+ return PixelFormat.UNKNOWN;
+ }
+
+ /**
+ * Sets the color used as the base color of this pattern drawable. The alpha component of the
+ * color will be ignored.
+ */
+ public void setColor(int color) {
+ final int r = Color.red(color);
+ final int g = Color.green(color);
+ final int b = Color.blue(color);
+ this.color = Color.argb(COLOR_ALPHA_INT, r, g, b);
+ invalidateSelf();
+ }
+
+ /**
+ * @return The color used as the base color of this pattern drawable. The alpha component of this
+ * is always 255.
+ */
+ public int getColor() {
+ return Color.argb(255, Color.red(color), Color.green(color), Color.blue(color));
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/GlifPreferenceLayout.java b/main/src/com/google/android/setupdesign/GlifPreferenceLayout.java
new file mode 100644
index 0000000..5121e86
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/GlifPreferenceLayout.java
@@ -0,0 +1,115 @@
+/*
+ * Copyright (C) 2016 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.google.android.setupdesign;
+
+import android.content.Context;
+import android.os.Bundle;
+import androidx.recyclerview.widget.RecyclerView;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import com.google.android.setupdesign.template.RecyclerMixin;
+
+/**
+ * A layout to be used with {@code PreferenceFragment} in v14 support library. This can be specified
+ * as the {@code android:layout} in the {@code app:preferenceFragmentStyle} in {@code
+ * app:preferenceTheme}.
+ *
+ * <p>Example:
+ *
+ * <pre>{@code
+ * &lt;style android:name="MyActivityTheme">
+ * &lt;item android:name="preferenceTheme">@style/MyPreferenceTheme&lt;/item>
+ * &lt;/style>
+ *
+ * &lt;style android:name="MyPreferenceTheme">
+ * &lt;item android:name="preferenceFragmentStyle">@style/MyPreferenceFragmentStyle&lt;/item>
+ * &lt;/style>
+ *
+ * &lt;style android:name="MyPreferenceFragmentStyle">
+ * &lt;item android:name="android:layout">@layout/my_preference_layout&lt;/item>
+ * &lt;/style>
+ * }</pre>
+ *
+ * where {@code my_preference_layout} is a layout that contains {@link
+ * com.google.android.setupdesign.GlifPreferenceLayout}.
+ *
+ * <p>Example:
+ *
+ * <pre>{@code
+ * &lt;com.google.android.setupdesign.GlifPreferenceLayout
+ * xmlns:android="http://schemas.android.com/apk/res/android"
+ * android:id="@id/list_container"
+ * android:layout_width="match_parent"
+ * android:layout_height="match_parent" />
+ * }</pre>
+ *
+ * <p>Fragments using this layout <em>must</em> delegate {@code onCreateRecyclerView} to the
+ * implementation in this class: {@link #onCreateRecyclerView(android.view.LayoutInflater,
+ * android.view.ViewGroup, android.os.Bundle)}
+ */
+public class GlifPreferenceLayout extends GlifRecyclerLayout {
+
+ public GlifPreferenceLayout(Context context) {
+ super(context);
+ }
+
+ public GlifPreferenceLayout(Context context, int template, int containerId) {
+ super(context, template, containerId);
+ }
+
+ public GlifPreferenceLayout(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public GlifPreferenceLayout(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+
+ @Override
+ protected ViewGroup findContainer(int containerId) {
+ if (containerId == 0) {
+ containerId = R.id.suw_layout_content;
+ }
+ return super.findContainer(containerId);
+ }
+
+ /** This method must be called in {@code PreferenceFragment#onCreateRecyclerView}. */
+ public RecyclerView onCreateRecyclerView(
+ LayoutInflater inflater, ViewGroup parent, Bundle savedInstanceState) {
+ return recyclerMixin.getRecyclerView();
+ }
+
+ @Override
+ protected View onInflateTemplate(LayoutInflater inflater, int template) {
+ if (template == 0) {
+ template = R.layout.suw_glif_preference_template;
+ }
+ return super.onInflateTemplate(inflater, template);
+ }
+
+ @Override
+ protected void onTemplateInflated() {
+ // Inflate the recycler view here, so attributes on the decoration views can be applied
+ // immediately.
+ final LayoutInflater inflater = LayoutInflater.from(getContext());
+ RecyclerView recyclerView =
+ (RecyclerView) inflater.inflate(R.layout.suw_glif_preference_recycler_view, this, false);
+ recyclerMixin = new RecyclerMixin(this, recyclerView);
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/GlifRecyclerLayout.java b/main/src/com/google/android/setupdesign/GlifRecyclerLayout.java
new file mode 100644
index 0000000..ac7d7e5
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/GlifRecyclerLayout.java
@@ -0,0 +1,173 @@
+/*
+ * Copyright (C) 2015 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.google.android.setupdesign;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.os.Build.VERSION_CODES;
+import androidx.recyclerview.widget.RecyclerView;
+import androidx.recyclerview.widget.RecyclerView.Adapter;
+import androidx.recyclerview.widget.RecyclerView.ViewHolder;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import com.google.android.setupdesign.template.RecyclerMixin;
+import com.google.android.setupdesign.template.RecyclerViewScrollHandlingDelegate;
+import com.google.android.setupdesign.template.RequireScrollMixin;
+
+/**
+ * A GLIF themed layout with a RecyclerView. {@code android:entries} can also be used to specify an
+ * {@link com.google.android.setupdesign.items.ItemHierarchy} to be used with this layout in XML.
+ */
+public class GlifRecyclerLayout extends GlifLayout {
+
+ protected RecyclerMixin recyclerMixin;
+
+ public GlifRecyclerLayout(Context context) {
+ this(context, 0, 0);
+ }
+
+ public GlifRecyclerLayout(Context context, int template) {
+ this(context, template, 0);
+ }
+
+ public GlifRecyclerLayout(Context context, int template, int containerId) {
+ super(context, template, containerId);
+ init(null, 0);
+ }
+
+ public GlifRecyclerLayout(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init(attrs, 0);
+ }
+
+ @TargetApi(VERSION_CODES.HONEYCOMB)
+ public GlifRecyclerLayout(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ init(attrs, defStyleAttr);
+ }
+
+ private void init(AttributeSet attrs, int defStyleAttr) {
+ recyclerMixin.parseAttributes(attrs, defStyleAttr);
+ registerMixin(RecyclerMixin.class, recyclerMixin);
+
+ final RequireScrollMixin requireScrollMixin = getMixin(RequireScrollMixin.class);
+ requireScrollMixin.setScrollHandlingDelegate(
+ new RecyclerViewScrollHandlingDelegate(requireScrollMixin, getRecyclerView()));
+ }
+
+ @Override
+ protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
+ super.onLayout(changed, left, top, right, bottom);
+ recyclerMixin.onLayout();
+ }
+
+ @Override
+ protected View onInflateTemplate(LayoutInflater inflater, int template) {
+ if (template == 0) {
+ template = R.layout.suw_glif_recycler_template;
+ }
+ return super.onInflateTemplate(inflater, template);
+ }
+
+ @Override
+ protected void onTemplateInflated() {
+ final View recyclerView = findViewById(R.id.suw_recycler_view);
+ if (recyclerView instanceof RecyclerView) {
+ recyclerMixin = new RecyclerMixin(this, (RecyclerView) recyclerView);
+ } else {
+ throw new IllegalStateException(
+ "GlifRecyclerLayout should use a template with recycler view");
+ }
+ }
+
+ @Override
+ protected ViewGroup findContainer(int containerId) {
+ if (containerId == 0) {
+ containerId = R.id.suw_recycler_view;
+ }
+ return super.findContainer(containerId);
+ }
+
+ @Override
+ // Returning generic type is the common pattern used for findViewBy* methods
+ @SuppressWarnings("TypeParameterUnusedInFormals")
+ public <T extends View> T findManagedViewById(int id) {
+ final View header = recyclerMixin.getHeader();
+ if (header != null) {
+ final T view = header.findViewById(id);
+ if (view != null) {
+ return view;
+ }
+ }
+ return super.findViewById(id);
+ }
+
+ /** @see RecyclerMixin#setDividerItemDecoration(DividerItemDecoration) */
+ public void setDividerItemDecoration(DividerItemDecoration decoration) {
+ recyclerMixin.setDividerItemDecoration(decoration);
+ }
+
+ /** @see RecyclerMixin#getRecyclerView() */
+ public RecyclerView getRecyclerView() {
+ return recyclerMixin.getRecyclerView();
+ }
+
+ /** @see RecyclerMixin#setAdapter(Adapter) */
+ public void setAdapter(Adapter<? extends ViewHolder> adapter) {
+ recyclerMixin.setAdapter(adapter);
+ }
+
+ /** @see RecyclerMixin#getAdapter() */
+ public Adapter<? extends ViewHolder> getAdapter() {
+ return recyclerMixin.getAdapter();
+ }
+
+ /** @deprecated Use {@link #setDividerInsets(int, int)} instead. */
+ @Deprecated
+ public void setDividerInset(int inset) {
+ recyclerMixin.setDividerInset(inset);
+ }
+
+ /** @see RecyclerMixin#setDividerInset(int) */
+ public void setDividerInsets(int start, int end) {
+ recyclerMixin.setDividerInsets(start, end);
+ }
+
+ /** @deprecated Use {@link #getDividerInsetStart()} instead. */
+ @Deprecated
+ public int getDividerInset() {
+ return recyclerMixin.getDividerInset();
+ }
+
+ /** @see RecyclerMixin#getDividerInsetStart() */
+ public int getDividerInsetStart() {
+ return recyclerMixin.getDividerInsetStart();
+ }
+
+ /** @see RecyclerMixin#getDividerInsetEnd() */
+ public int getDividerInsetEnd() {
+ return recyclerMixin.getDividerInsetEnd();
+ }
+
+ /** @see RecyclerMixin#getDivider() */
+ public Drawable getDivider() {
+ return recyclerMixin.getDivider();
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/SetupWizardItemsLayout.java b/main/src/com/google/android/setupdesign/SetupWizardItemsLayout.java
new file mode 100644
index 0000000..2f3dd86
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/SetupWizardItemsLayout.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright (C) 2015 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.google.android.setupdesign;
+
+import android.content.Context;
+import androidx.annotation.Nullable;
+import android.util.AttributeSet;
+import android.widget.ListAdapter;
+import com.google.android.setupdesign.items.ItemAdapter;
+
+/** @deprecated Use {@link SetupWizardListLayout} instead. */
+@Deprecated
+public class SetupWizardItemsLayout extends SetupWizardListLayout {
+
+ public SetupWizardItemsLayout(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public SetupWizardItemsLayout(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+
+ @Override
+ @Nullable
+ public ItemAdapter getAdapter() {
+ final ListAdapter adapter = super.getAdapter();
+ if (adapter instanceof ItemAdapter) {
+ return (ItemAdapter) adapter;
+ }
+ return null;
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/SetupWizardLayout.java b/main/src/com/google/android/setupdesign/SetupWizardLayout.java
new file mode 100644
index 0000000..608646a
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/SetupWizardLayout.java
@@ -0,0 +1,425 @@
+/*
+ * Copyright (C) 2015 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.google.android.setupdesign;
+
+import android.annotation.SuppressLint;
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.content.res.ColorStateList;
+import android.content.res.TypedArray;
+import android.graphics.Shader.TileMode;
+import android.graphics.drawable.BitmapDrawable;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.LayerDrawable;
+import android.os.Build.VERSION;
+import android.os.Build.VERSION_CODES;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.util.TypedValue;
+import android.view.Gravity;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ScrollView;
+import android.widget.TextView;
+import com.google.android.setupcompat.TemplateLayout;
+import com.google.android.setupdesign.template.HeaderMixin;
+import com.google.android.setupdesign.template.NavigationBarMixin;
+import com.google.android.setupdesign.template.ProgressBarMixin;
+import com.google.android.setupdesign.template.RequireScrollMixin;
+import com.google.android.setupdesign.template.ScrollViewScrollHandlingDelegate;
+import com.google.android.setupdesign.view.Illustration;
+import com.google.android.setupdesign.view.NavigationBar;
+
+public class SetupWizardLayout extends TemplateLayout {
+
+ private static final String TAG = "SetupWizardLayout";
+
+ public SetupWizardLayout(Context context) {
+ super(context, 0, 0);
+ init(null, R.attr.suwLayoutTheme);
+ }
+
+ public SetupWizardLayout(Context context, int template) {
+ this(context, template, 0);
+ }
+
+ public SetupWizardLayout(Context context, int template, int containerId) {
+ super(context, template, containerId);
+ init(null, R.attr.suwLayoutTheme);
+ }
+
+ public SetupWizardLayout(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init(attrs, R.attr.suwLayoutTheme);
+ }
+
+ @TargetApi(VERSION_CODES.HONEYCOMB)
+ public SetupWizardLayout(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ init(attrs, defStyleAttr);
+ }
+
+ // All the constructors delegate to this init method. The 3-argument constructor is not
+ // available in LinearLayout before v11, so call super with the exact same arguments.
+ private void init(AttributeSet attrs, int defStyleAttr) {
+ registerMixin(HeaderMixin.class, new HeaderMixin(this, attrs, defStyleAttr));
+ registerMixin(ProgressBarMixin.class, new ProgressBarMixin(this));
+ registerMixin(NavigationBarMixin.class, new NavigationBarMixin(this));
+ final RequireScrollMixin requireScrollMixin = new RequireScrollMixin(this);
+ registerMixin(RequireScrollMixin.class, requireScrollMixin);
+
+ final ScrollView scrollView = getScrollView();
+ if (scrollView != null) {
+ requireScrollMixin.setScrollHandlingDelegate(
+ new ScrollViewScrollHandlingDelegate(requireScrollMixin, scrollView));
+ }
+
+ final TypedArray a =
+ getContext()
+ .obtainStyledAttributes(attrs, R.styleable.SuwSetupWizardLayout, defStyleAttr, 0);
+
+ // Set the background from XML, either directly or built from a bitmap tile
+ final Drawable background = a.getDrawable(R.styleable.SuwSetupWizardLayout_suwBackground);
+ if (background != null) {
+ setLayoutBackground(background);
+ } else {
+ final Drawable backgroundTile =
+ a.getDrawable(R.styleable.SuwSetupWizardLayout_suwBackgroundTile);
+ if (backgroundTile != null) {
+ setBackgroundTile(backgroundTile);
+ }
+ }
+
+ // Set the illustration from XML, either directly or built from image + horizontal tile
+ final Drawable illustration = a.getDrawable(R.styleable.SuwSetupWizardLayout_suwIllustration);
+ if (illustration != null) {
+ setIllustration(illustration);
+ } else {
+ final Drawable illustrationImage =
+ a.getDrawable(R.styleable.SuwSetupWizardLayout_suwIllustrationImage);
+ final Drawable horizontalTile =
+ a.getDrawable(R.styleable.SuwSetupWizardLayout_suwIllustrationHorizontalTile);
+ if (illustrationImage != null && horizontalTile != null) {
+ setIllustration(illustrationImage, horizontalTile);
+ }
+ }
+
+ // Set the top padding of the illustration
+ int decorPaddingTop =
+ a.getDimensionPixelSize(R.styleable.SuwSetupWizardLayout_suwDecorPaddingTop, -1);
+ if (decorPaddingTop == -1) {
+ decorPaddingTop = getResources().getDimensionPixelSize(R.dimen.suw_decor_padding_top);
+ }
+ setDecorPaddingTop(decorPaddingTop);
+
+ // Set the illustration aspect ratio. See Illustration.setAspectRatio(float). This will
+ // override suwDecorPaddingTop if its value is not 0.
+ float illustrationAspectRatio =
+ a.getFloat(R.styleable.SuwSetupWizardLayout_suwIllustrationAspectRatio, -1f);
+ if (illustrationAspectRatio == -1f) {
+ final TypedValue out = new TypedValue();
+ getResources().getValue(R.dimen.suw_illustration_aspect_ratio, out, true);
+ illustrationAspectRatio = out.getFloat();
+ }
+ setIllustrationAspectRatio(illustrationAspectRatio);
+
+ a.recycle();
+ }
+
+ @Override
+ protected Parcelable onSaveInstanceState() {
+ final Parcelable parcelable = super.onSaveInstanceState();
+ final SavedState ss = new SavedState(parcelable);
+ ss.isProgressBarShown = isProgressBarShown();
+ return ss;
+ }
+
+ @Override
+ protected void onRestoreInstanceState(Parcelable state) {
+ if (!(state instanceof SavedState)) {
+ Log.w(TAG, "Ignoring restore instance state " + state);
+ super.onRestoreInstanceState(state);
+ return;
+ }
+
+ final SavedState ss = (SavedState) state;
+ super.onRestoreInstanceState(ss.getSuperState());
+ final boolean isProgressBarShown = ss.isProgressBarShown;
+ setProgressBarShown(isProgressBarShown);
+ }
+
+ @Override
+ protected View onInflateTemplate(LayoutInflater inflater, int template) {
+ if (template == 0) {
+ template = R.layout.suw_template;
+ }
+ return inflateTemplate(inflater, R.style.SuwThemeMaterial_Light, template);
+ }
+
+ @Override
+ protected ViewGroup findContainer(int containerId) {
+ if (containerId == 0) {
+ containerId = R.id.suw_layout_content;
+ }
+ return super.findContainer(containerId);
+ }
+
+ public NavigationBar getNavigationBar() {
+ return getMixin(NavigationBarMixin.class).getNavigationBar();
+ }
+
+ public ScrollView getScrollView() {
+ final View view = findManagedViewById(R.id.suw_bottom_scroll_view);
+ return view instanceof ScrollView ? (ScrollView) view : null;
+ }
+
+ public void requireScrollToBottom() {
+ final RequireScrollMixin requireScrollMixin = getMixin(RequireScrollMixin.class);
+ final NavigationBar navigationBar = getNavigationBar();
+ if (navigationBar != null) {
+ requireScrollMixin.requireScrollWithNavigationBar(navigationBar);
+ } else {
+ Log.e(TAG, "Cannot require scroll. Navigation bar is null.");
+ }
+ }
+
+ public void setHeaderText(int title) {
+ getMixin(HeaderMixin.class).setText(title);
+ }
+
+ public void setHeaderText(CharSequence title) {
+ getMixin(HeaderMixin.class).setText(title);
+ }
+
+ public CharSequence getHeaderText() {
+ return getMixin(HeaderMixin.class).getText();
+ }
+
+ public TextView getHeaderTextView() {
+ return getMixin(HeaderMixin.class).getTextView();
+ }
+
+ /**
+ * Set the illustration of the layout. The drawable will be applied as is, and the bounds will be
+ * set as implemented in {@link com.google.android.setupdesign.view.Illustration}. To create a
+ * suitable drawable from an asset and a horizontal repeating tile, use {@link
+ * #setIllustration(int, int)} instead.
+ *
+ * @param drawable The drawable specifying the illustration.
+ */
+ public void setIllustration(Drawable drawable) {
+ final View view = findManagedViewById(R.id.suw_layout_decor);
+ if (view instanceof Illustration) {
+ final Illustration illustration = (Illustration) view;
+ illustration.setIllustration(drawable);
+ }
+ }
+
+ /**
+ * Set the illustration of the layout, which will be created asset and the horizontal tile as
+ * suitable. On phone layouts (not sw600dp), the asset will be scaled, maintaining aspect ratio.
+ * On tablets (sw600dp), the assets will always have 256dp height and the rest of the illustration
+ * area that the asset doesn't fill will be covered by the horizontalTile.
+ *
+ * @param asset Resource ID of the illustration asset.
+ * @param horizontalTile Resource ID of the horizontally repeating tile for tablet layout.
+ */
+ public void setIllustration(int asset, int horizontalTile) {
+ final View view = findManagedViewById(R.id.suw_layout_decor);
+ if (view instanceof Illustration) {
+ final Illustration illustration = (Illustration) view;
+ final Drawable illustrationDrawable = getIllustration(asset, horizontalTile);
+ illustration.setIllustration(illustrationDrawable);
+ }
+ }
+
+ private void setIllustration(Drawable asset, Drawable horizontalTile) {
+ final View view = findManagedViewById(R.id.suw_layout_decor);
+ if (view instanceof Illustration) {
+ final Illustration illustration = (Illustration) view;
+ final Drawable illustrationDrawable = getIllustration(asset, horizontalTile);
+ illustration.setIllustration(illustrationDrawable);
+ }
+ }
+
+ /**
+ * Sets the aspect ratio of the illustration. This will be the space (padding top) reserved above
+ * the header text. This will override the padding top of the illustration.
+ *
+ * @param aspectRatio The aspect ratio
+ * @see com.google.android.setupdesign.view.Illustration#setAspectRatio(float)
+ */
+ public void setIllustrationAspectRatio(float aspectRatio) {
+ final View view = findManagedViewById(R.id.suw_layout_decor);
+ if (view instanceof Illustration) {
+ final Illustration illustration = (Illustration) view;
+ illustration.setAspectRatio(aspectRatio);
+ }
+ }
+
+ /**
+ * Set the top padding of the decor view. If the decor is an Illustration and the aspect ratio is
+ * set, this value will be overridden.
+ *
+ * <p>Note: Currently the default top padding for tablet landscape is 128dp, which is the offset
+ * of the card from the top. This is likely to change in future versions so this value aligns with
+ * the height of the illustration instead.
+ *
+ * @param paddingTop The top padding in pixels.
+ */
+ public void setDecorPaddingTop(int paddingTop) {
+ final View view = findManagedViewById(R.id.suw_layout_decor);
+ if (view != null) {
+ view.setPadding(
+ view.getPaddingLeft(), paddingTop, view.getPaddingRight(), view.getPaddingBottom());
+ }
+ }
+
+ /**
+ * Set the background of the layout, which is expected to be able to extend infinitely. If it is a
+ * bitmap tile and you want it to repeat, use {@link #setBackgroundTile(int)} instead.
+ */
+ public void setLayoutBackground(Drawable background) {
+ final View view = findManagedViewById(R.id.suw_layout_decor);
+ if (view != null) {
+ //noinspection deprecation
+ view.setBackgroundDrawable(background);
+ }
+ }
+
+ /**
+ * Set the background of the layout to a repeating bitmap tile. To use a different kind of
+ * drawable, use {@link #setLayoutBackground(android.graphics.drawable.Drawable)} instead.
+ */
+ public void setBackgroundTile(int backgroundTile) {
+ final Drawable backgroundTileDrawable = getContext().getResources().getDrawable(backgroundTile);
+ setBackgroundTile(backgroundTileDrawable);
+ }
+
+ private void setBackgroundTile(Drawable backgroundTile) {
+ if (backgroundTile instanceof BitmapDrawable) {
+ ((BitmapDrawable) backgroundTile).setTileModeXY(TileMode.REPEAT, TileMode.REPEAT);
+ }
+ setLayoutBackground(backgroundTile);
+ }
+
+ private Drawable getIllustration(int asset, int horizontalTile) {
+ final Context context = getContext();
+ final Drawable assetDrawable = context.getResources().getDrawable(asset);
+ final Drawable tile = context.getResources().getDrawable(horizontalTile);
+ return getIllustration(assetDrawable, tile);
+ }
+
+ @SuppressLint("RtlHardcoded")
+ private Drawable getIllustration(Drawable asset, Drawable horizontalTile) {
+ final Context context = getContext();
+ if (context.getResources().getBoolean(R.bool.suwUseTabletLayout)) {
+ // If it is a "tablet" (sw600dp), create a LayerDrawable with the horizontal tile.
+ if (horizontalTile instanceof BitmapDrawable) {
+ ((BitmapDrawable) horizontalTile).setTileModeX(TileMode.REPEAT);
+ ((BitmapDrawable) horizontalTile).setGravity(Gravity.TOP);
+ }
+ if (asset instanceof BitmapDrawable) {
+ // Always specify TOP | LEFT, Illustration will flip the entire LayerDrawable.
+ ((BitmapDrawable) asset).setGravity(Gravity.TOP | Gravity.LEFT);
+ }
+ final LayerDrawable layers = new LayerDrawable(new Drawable[] {horizontalTile, asset});
+ if (VERSION.SDK_INT >= VERSION_CODES.KITKAT) {
+ layers.setAutoMirrored(true);
+ }
+ return layers;
+ } else {
+ // If it is a "phone" (not sw600dp), simply return the illustration
+ if (VERSION.SDK_INT >= VERSION_CODES.KITKAT) {
+ asset.setAutoMirrored(true);
+ }
+ return asset;
+ }
+ }
+
+ public boolean isProgressBarShown() {
+ return getMixin(ProgressBarMixin.class).isShown();
+ }
+
+ /**
+ * Sets whether the progress bar below the header text is shown or not. The progress bar is a
+ * lazily inflated ViewStub, which means the progress bar will not actually be part of the view
+ * hierarchy until the first time this is set to {@code true}.
+ */
+ public void setProgressBarShown(boolean shown) {
+ getMixin(ProgressBarMixin.class).setShown(shown);
+ }
+
+ /** @deprecated Use {@link #setProgressBarShown(boolean)} */
+ @Deprecated
+ public void showProgressBar() {
+ setProgressBarShown(true);
+ }
+
+ /** @deprecated Use {@link #setProgressBarShown(boolean)} */
+ @Deprecated
+ public void hideProgressBar() {
+ setProgressBarShown(false);
+ }
+
+ public void setProgressBarColor(ColorStateList color) {
+ getMixin(ProgressBarMixin.class).setColor(color);
+ }
+
+ public ColorStateList getProgressBarColor() {
+ return getMixin(ProgressBarMixin.class).getColor();
+ }
+
+ /* Misc */
+
+ protected static class SavedState extends BaseSavedState {
+
+ boolean isProgressBarShown = false;
+
+ public SavedState(Parcelable parcelable) {
+ super(parcelable);
+ }
+
+ public SavedState(Parcel source) {
+ super(source);
+ isProgressBarShown = source.readInt() != 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ super.writeToParcel(dest, flags);
+ dest.writeInt(isProgressBarShown ? 1 : 0);
+ }
+
+ public static final Parcelable.Creator<SavedState> CREATOR =
+ new Parcelable.Creator<SavedState>() {
+
+ @Override
+ public SavedState createFromParcel(Parcel parcel) {
+ return new SavedState(parcel);
+ }
+
+ @Override
+ public SavedState[] newArray(int size) {
+ return new SavedState[size];
+ }
+ };
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/SetupWizardListLayout.java b/main/src/com/google/android/setupdesign/SetupWizardListLayout.java
new file mode 100644
index 0000000..38358d9
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/SetupWizardListLayout.java
@@ -0,0 +1,153 @@
+/*
+ * Copyright (C) 2015 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.google.android.setupdesign;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.os.Build.VERSION_CODES;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ListAdapter;
+import android.widget.ListView;
+import com.google.android.setupdesign.template.ListMixin;
+import com.google.android.setupdesign.template.ListViewScrollHandlingDelegate;
+import com.google.android.setupdesign.template.RequireScrollMixin;
+
+public class SetupWizardListLayout extends SetupWizardLayout {
+
+ private ListMixin listMixin;
+
+ public SetupWizardListLayout(Context context) {
+ this(context, 0, 0);
+ }
+
+ public SetupWizardListLayout(Context context, int template) {
+ this(context, template, 0);
+ }
+
+ public SetupWizardListLayout(Context context, int template, int containerId) {
+ super(context, template, containerId);
+ init(null, 0);
+ }
+
+ public SetupWizardListLayout(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init(attrs, 0);
+ }
+
+ @TargetApi(VERSION_CODES.HONEYCOMB)
+ public SetupWizardListLayout(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ init(attrs, defStyleAttr);
+ }
+
+ private void init(AttributeSet attrs, int defStyleAttr) {
+ listMixin = new ListMixin(this, attrs, defStyleAttr);
+ registerMixin(ListMixin.class, listMixin);
+
+ final RequireScrollMixin requireScrollMixin = getMixin(RequireScrollMixin.class);
+ requireScrollMixin.setScrollHandlingDelegate(
+ new ListViewScrollHandlingDelegate(requireScrollMixin, getListView()));
+ }
+
+ @Override
+ protected View onInflateTemplate(LayoutInflater inflater, int template) {
+ if (template == 0) {
+ template = R.layout.suw_list_template;
+ }
+ return super.onInflateTemplate(inflater, template);
+ }
+
+ @Override
+ protected ViewGroup findContainer(int containerId) {
+ if (containerId == 0) {
+ containerId = android.R.id.list;
+ }
+ return super.findContainer(containerId);
+ }
+
+ @Override
+ protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
+ super.onLayout(changed, left, top, right, bottom);
+ listMixin.onLayout();
+ }
+
+ public ListView getListView() {
+ return listMixin.getListView();
+ }
+
+ public void setAdapter(ListAdapter adapter) {
+ listMixin.setAdapter(adapter);
+ }
+
+ public ListAdapter getAdapter() {
+ return listMixin.getAdapter();
+ }
+
+ /**
+ * Sets the start inset of the divider. This will use the default divider drawable set in the
+ * theme and inset it {@code inset} pixels to the right (or left in RTL layouts).
+ *
+ * @param inset The number of pixels to inset on the "start" side of the list divider. Typically
+ * this will be either {@code @dimen/suw_items_icon_divider_inset} or
+ * {@code @dimen/suw_items_text_divider_inset}.
+ * @see ListMixin#setDividerInset(int)
+ * @deprecated Use {@link #setDividerInsets(int, int)} instead.
+ */
+ @Deprecated
+ public void setDividerInset(int inset) {
+ listMixin.setDividerInset(inset);
+ }
+
+ /**
+ * Sets the start inset of the divider. This will use the default divider drawable set in the
+ * theme and apply insets to it.
+ *
+ * @param start The number of pixels to inset on the "start" side of the list divider. Typically
+ * this will be either {@code @dimen/suw_items_icon_divider_inset} or
+ * {@code @dimen/suw_items_text_divider_inset}.
+ * @param end The number of pixels to inset on the "end" side of the list divider.
+ * @see ListMixin#setDividerInsets(int, int)
+ */
+ public void setDividerInsets(int start, int end) {
+ listMixin.setDividerInsets(start, end);
+ }
+
+ /** @deprecated Use {@link #getDividerInsetStart()} instead. */
+ @Deprecated
+ public int getDividerInset() {
+ return listMixin.getDividerInset();
+ }
+
+ /** @see ListMixin#getDividerInsetStart() */
+ public int getDividerInsetStart() {
+ return listMixin.getDividerInsetStart();
+ }
+
+ /** @see ListMixin#getDividerInsetEnd() */
+ public int getDividerInsetEnd() {
+ return listMixin.getDividerInsetEnd();
+ }
+
+ /** @see ListMixin#getDivider() */
+ public Drawable getDivider() {
+ return listMixin.getDivider();
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/SetupWizardPreferenceLayout.java b/main/src/com/google/android/setupdesign/SetupWizardPreferenceLayout.java
new file mode 100644
index 0000000..e4d38c6
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/SetupWizardPreferenceLayout.java
@@ -0,0 +1,114 @@
+/*
+ * Copyright (C) 2016 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.google.android.setupdesign;
+
+import android.content.Context;
+import android.os.Bundle;
+import androidx.recyclerview.widget.RecyclerView;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import com.google.android.setupdesign.template.RecyclerMixin;
+
+/**
+ * A layout to be used with {@code PreferenceFragment} in v14 support library. This can be specified
+ * as the {@code android:layout} in the {@code app:preferenceFragmentStyle} in {@code
+ * app:preferenceTheme}.
+ *
+ * <p>Example:
+ *
+ * <pre>{@code
+ * &lt;style android:name="MyActivityTheme">
+ * &lt;item android:name="preferenceTheme">@style/MyPreferenceTheme&lt;/item>
+ * &lt;/style>
+ *
+ * &lt;style android:name="MyPreferenceTheme">
+ * &lt;item android:name="preferenceFragmentStyle">@style/MyPreferenceFragmentStyle&lt;/item>
+ * &lt;/style>
+ *
+ * &lt;style android:name="MyPreferenceFragmentStyle">
+ * &lt;item android:name="android:layout">@layout/my_preference_layout&lt;/item>
+ * &lt;/style>
+ * }</pre>
+ *
+ * where {@code my_preference_layout} is a layout that contains {@link
+ * com.google.android.setupdesign.SetupWizardPreferenceLayout}.
+ *
+ * <p>Example:
+ *
+ * <pre>{@code
+ * &lt;com.google.android.setupdesign.SetupWizardPreferenceLayout
+ * xmlns:android="http://schemas.android.com/apk/res/android"
+ * android:id="@id/list_container"
+ * android:layout_width="match_parent"
+ * android:layout_height="match_parent" />
+ * }</pre>
+ *
+ * <p>Fragments using this layout <em>must</em> delegate {@code onCreateRecyclerView} to the
+ * implementation in this class: {@link #onCreateRecyclerView}
+ */
+public class SetupWizardPreferenceLayout extends SetupWizardRecyclerLayout {
+
+ public SetupWizardPreferenceLayout(Context context) {
+ super(context);
+ }
+
+ public SetupWizardPreferenceLayout(Context context, int template, int containerId) {
+ super(context, template, containerId);
+ }
+
+ public SetupWizardPreferenceLayout(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public SetupWizardPreferenceLayout(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+
+ @Override
+ protected ViewGroup findContainer(int containerId) {
+ if (containerId == 0) {
+ containerId = R.id.suw_layout_content;
+ }
+ return super.findContainer(containerId);
+ }
+
+ /** This method must be called in {@code PreferenceFragment#onCreateRecyclerView}. */
+ public RecyclerView onCreateRecyclerView(
+ LayoutInflater inflater, ViewGroup parent, Bundle savedInstanceState) {
+ return recyclerMixin.getRecyclerView();
+ }
+
+ @Override
+ protected View onInflateTemplate(LayoutInflater inflater, int template) {
+ if (template == 0) {
+ template = R.layout.suw_preference_template;
+ }
+ return super.onInflateTemplate(inflater, template);
+ }
+
+ @Override
+ protected void onTemplateInflated() {
+ // Inflate the recycler view here, so attributes on the decoration views can be applied
+ // immediately.
+ final LayoutInflater inflater = LayoutInflater.from(getContext());
+ RecyclerView recyclerView =
+ (RecyclerView) inflater.inflate(R.layout.suw_preference_recycler_view, this, false);
+ recyclerMixin = new RecyclerMixin(this, recyclerView);
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/SetupWizardRecyclerLayout.java b/main/src/com/google/android/setupdesign/SetupWizardRecyclerLayout.java
new file mode 100644
index 0000000..4313123
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/SetupWizardRecyclerLayout.java
@@ -0,0 +1,173 @@
+/*
+ * Copyright (C) 2015 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.google.android.setupdesign;
+
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import androidx.recyclerview.widget.RecyclerView;
+import androidx.recyclerview.widget.RecyclerView.Adapter;
+import androidx.recyclerview.widget.RecyclerView.ViewHolder;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import com.google.android.setupdesign.template.RecyclerMixin;
+import com.google.android.setupdesign.template.RecyclerViewScrollHandlingDelegate;
+import com.google.android.setupdesign.template.RequireScrollMixin;
+
+/**
+ * A setup wizard layout for use with {@link androidx.recyclerview.widget.RecyclerView}. {@code
+ * android:entries} can also be used to specify an {@link
+ * com.google.android.setupdesign.items.ItemHierarchy} to be used with this layout in XML.
+ *
+ * @see SetupWizardListLayout
+ */
+public class SetupWizardRecyclerLayout extends SetupWizardLayout {
+
+ protected RecyclerMixin recyclerMixin;
+
+ public SetupWizardRecyclerLayout(Context context) {
+ this(context, 0, 0);
+ }
+
+ public SetupWizardRecyclerLayout(Context context, int template, int containerId) {
+ super(context, template, containerId);
+ init(null, 0);
+ }
+
+ public SetupWizardRecyclerLayout(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init(attrs, 0);
+ }
+
+ public SetupWizardRecyclerLayout(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ init(attrs, defStyleAttr);
+ }
+
+ private void init(AttributeSet attrs, int defStyleAttr) {
+ recyclerMixin.parseAttributes(attrs, defStyleAttr);
+ registerMixin(RecyclerMixin.class, recyclerMixin);
+
+ final RequireScrollMixin requireScrollMixin = getMixin(RequireScrollMixin.class);
+ requireScrollMixin.setScrollHandlingDelegate(
+ new RecyclerViewScrollHandlingDelegate(requireScrollMixin, getRecyclerView()));
+ }
+
+ @Override
+ protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
+ super.onLayout(changed, left, top, right, bottom);
+ recyclerMixin.onLayout();
+ }
+
+ /** @see RecyclerMixin#getAdapter() */
+ public Adapter<? extends ViewHolder> getAdapter() {
+ return recyclerMixin.getAdapter();
+ }
+
+ /** @see RecyclerMixin#setAdapter(Adapter) */
+ public void setAdapter(Adapter<? extends ViewHolder> adapter) {
+ recyclerMixin.setAdapter(adapter);
+ }
+
+ /** @see RecyclerMixin#getRecyclerView() */
+ public RecyclerView getRecyclerView() {
+ return recyclerMixin.getRecyclerView();
+ }
+
+ @Override
+ protected ViewGroup findContainer(int containerId) {
+ if (containerId == 0) {
+ containerId = R.id.suw_recycler_view;
+ }
+ return super.findContainer(containerId);
+ }
+
+ @Override
+ protected View onInflateTemplate(LayoutInflater inflater, int template) {
+ if (template == 0) {
+ template = R.layout.suw_recycler_template;
+ }
+ return super.onInflateTemplate(inflater, template);
+ }
+
+ @Override
+ protected void onTemplateInflated() {
+ final View recyclerView = findViewById(R.id.suw_recycler_view);
+ if (recyclerView instanceof RecyclerView) {
+ recyclerMixin = new RecyclerMixin(this, (RecyclerView) recyclerView);
+ } else {
+ throw new IllegalStateException(
+ "SetupWizardRecyclerLayout should use a template with recycler view");
+ }
+ }
+
+ @Override
+ // Returning generic type is the common pattern used for findViewBy* methods
+ @SuppressWarnings("TypeParameterUnusedInFormals")
+ public <T extends View> T findManagedViewById(int id) {
+ final View header = recyclerMixin.getHeader();
+ if (header != null) {
+ final T view = header.findViewById(id);
+ if (view != null) {
+ return view;
+ }
+ }
+ return super.findViewById(id);
+ }
+
+ /** @deprecated Use {@link #setDividerInsets(int, int)} instead. */
+ @Deprecated
+ public void setDividerInset(int inset) {
+ recyclerMixin.setDividerInset(inset);
+ }
+
+ /**
+ * Sets the start inset of the divider. This will use the default divider drawable set in the
+ * theme and apply insets to it.
+ *
+ * @param start The number of pixels to inset on the "start" side of the list divider. Typically
+ * this will be either {@code @dimen/suw_items_icon_divider_inset} or
+ * {@code @dimen/suw_items_text_divider_inset}.
+ * @param end The number of pixels to inset on the "end" side of the list divider.
+ * @see RecyclerMixin#setDividerInsets(int, int)
+ */
+ public void setDividerInsets(int start, int end) {
+ recyclerMixin.setDividerInsets(start, end);
+ }
+
+ /** @deprecated Use {@link #getDividerInsetStart()} instead. */
+ @Deprecated
+ public int getDividerInset() {
+ return recyclerMixin.getDividerInset();
+ }
+
+ /** @see RecyclerMixin#getDividerInsetStart() */
+ public int getDividerInsetStart() {
+ return recyclerMixin.getDividerInsetStart();
+ }
+
+ /** @see RecyclerMixin#getDividerInsetEnd() */
+ public int getDividerInsetEnd() {
+ return recyclerMixin.getDividerInsetEnd();
+ }
+
+ /** @see RecyclerMixin#getDivider() */
+ public Drawable getDivider() {
+ return recyclerMixin.getDivider();
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/gesture/ConsecutiveTapsGestureDetector.java b/main/src/com/google/android/setupdesign/gesture/ConsecutiveTapsGestureDetector.java
new file mode 100644
index 0000000..7ecae0e
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/gesture/ConsecutiveTapsGestureDetector.java
@@ -0,0 +1,109 @@
+/*
+ * Copyright (C) 2016 Google Inc.
+ *
+ * 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.google.android.setupdesign.gesture;
+
+import android.graphics.Rect;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewConfiguration;
+
+/**
+ * Helper class to detect the consective-tap gestures on a view.
+ *
+ * <p>This class is instantiated and used similar to a GestureDetector, where onTouchEvent should be
+ * called when there are MotionEvents this detector should know about.
+ */
+public final class ConsecutiveTapsGestureDetector {
+
+ public interface OnConsecutiveTapsListener {
+ /** Callback method when the user tapped on the target view X number of times. */
+ void onConsecutiveTaps(int numOfConsecutiveTaps);
+ }
+
+ private final View view;
+ private final OnConsecutiveTapsListener listener;
+ private final int consecutiveTapTouchSlopSquare;
+ private final int consecutiveTapTimeout;
+
+ private int consecutiveTapsCounter = 0;
+ private MotionEvent previousTapEvent;
+
+ /**
+ * @param listener The listener that responds to the gesture.
+ * @param view The target view that associated with consecutive-tap gesture.
+ */
+ public ConsecutiveTapsGestureDetector(OnConsecutiveTapsListener listener, View view) {
+ this.listener = listener;
+ this.view = view;
+ int doubleTapSlop = ViewConfiguration.get(this.view.getContext()).getScaledDoubleTapSlop();
+ consecutiveTapTouchSlopSquare = doubleTapSlop * doubleTapSlop;
+ consecutiveTapTimeout = ViewConfiguration.getDoubleTapTimeout();
+ }
+
+ /**
+ * This method should be called from the relevant activity or view, typically in onTouchEvent,
+ * onInterceptTouchEvent or dispatchTouchEvent.
+ *
+ * @param ev The motion event
+ */
+ public void onTouchEvent(MotionEvent ev) {
+ if (ev.getAction() == MotionEvent.ACTION_UP) {
+ Rect viewRect = new Rect();
+ int[] leftTop = new int[2];
+ view.getLocationOnScreen(leftTop);
+ viewRect.set(
+ leftTop[0], leftTop[1], leftTop[0] + view.getWidth(), leftTop[1] + view.getHeight());
+ if (viewRect.contains((int) ev.getX(), (int) ev.getY())) {
+ if (isConsecutiveTap(ev)) {
+ consecutiveTapsCounter++;
+ } else {
+ consecutiveTapsCounter = 1;
+ }
+ listener.onConsecutiveTaps(consecutiveTapsCounter);
+ } else {
+ // Touch outside the target view. Reset counter.
+ consecutiveTapsCounter = 0;
+ }
+
+ if (previousTapEvent != null) {
+ previousTapEvent.recycle();
+ }
+ previousTapEvent = MotionEvent.obtain(ev);
+ }
+ }
+
+ /** Resets the consecutive-tap counter to zero. */
+ public void resetCounter() {
+ consecutiveTapsCounter = 0;
+ }
+
+ /**
+ * Returns true if the distance between consecutive tap is within {@link
+ * #consecutiveTapTouchSlopSquare}. False, otherwise.
+ */
+ private boolean isConsecutiveTap(MotionEvent currentTapEvent) {
+ if (previousTapEvent == null) {
+ return false;
+ }
+
+ double deltaX = previousTapEvent.getX() - currentTapEvent.getX();
+ double deltaY = previousTapEvent.getY() - currentTapEvent.getY();
+ long deltaTime = currentTapEvent.getEventTime() - previousTapEvent.getEventTime();
+ return (deltaX * deltaX + deltaY * deltaY <= consecutiveTapTouchSlopSquare)
+ && deltaTime < consecutiveTapTimeout;
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/items/AbstractItem.java b/main/src/com/google/android/setupdesign/items/AbstractItem.java
new file mode 100644
index 0000000..e22431d
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/items/AbstractItem.java
@@ -0,0 +1,66 @@
+/*
+ * Copyright (C) 2015 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.google.android.setupdesign.items;
+
+import android.content.Context;
+import android.util.AttributeSet;
+
+/**
+ * Abstract implementation of an item, which implements {@link IItem} and takes care of implementing
+ * methods for {@link ItemHierarchy} for items representing itself.
+ */
+public abstract class AbstractItem extends AbstractItemHierarchy implements IItem {
+
+ public AbstractItem() {
+ super();
+ }
+
+ public AbstractItem(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @Override
+ public int getCount() {
+ return 1;
+ }
+
+ @Override
+ public IItem getItemAt(int position) {
+ return this;
+ }
+
+ @Override
+ public ItemHierarchy findItemById(int id) {
+ if (id == getId()) {
+ return this;
+ }
+ return null;
+ }
+
+ /**
+ * Convenience method to notify the adapter that the contents of this item has changed. This only
+ * includes non-structural changes. Changes that causes the item to be removed should use the
+ * other notification methods.
+ *
+ * @see #notifyItemRangeChanged(int, int)
+ * @see #notifyItemRangeInserted(int, int)
+ * @see #notifyItemRangeRemoved(int, int)
+ */
+ public void notifyItemChanged() {
+ notifyItemRangeChanged(0, 1);
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/items/AbstractItemHierarchy.java b/main/src/com/google/android/setupdesign/items/AbstractItemHierarchy.java
new file mode 100644
index 0000000..6cb5fab
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/items/AbstractItemHierarchy.java
@@ -0,0 +1,142 @@
+/*
+ * Copyright (C) 2015 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.google.android.setupdesign.items;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.util.AttributeSet;
+import android.util.Log;
+import com.google.android.setupdesign.R;
+import java.util.ArrayList;
+
+/** An abstract item hierarchy; provides default implementation for ID and observers. */
+public abstract class AbstractItemHierarchy implements ItemHierarchy {
+
+ /* static section */
+
+ private static final String TAG = "AbstractItemHierarchy";
+
+ /* non-static section */
+
+ private final ArrayList<Observer> observers = new ArrayList<>();
+ private int id = 0;
+
+ public AbstractItemHierarchy() {}
+
+ public AbstractItemHierarchy(Context context, AttributeSet attrs) {
+ TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.SuwAbstractItem);
+ id = a.getResourceId(R.styleable.SuwAbstractItem_android_id, 0);
+ a.recycle();
+ }
+
+ public void setId(int id) {
+ this.id = id;
+ }
+
+ public int getId() {
+ return id;
+ }
+
+ public int getViewId() {
+ return getId();
+ }
+
+ @Override
+ public void registerObserver(Observer observer) {
+ observers.add(observer);
+ }
+
+ @Override
+ public void unregisterObserver(Observer observer) {
+ observers.remove(observer);
+ }
+
+ /** @see Observer#onChanged(ItemHierarchy) */
+ public void notifyChanged() {
+ for (Observer observer : observers) {
+ observer.onChanged(this);
+ }
+ }
+
+ /** @see Observer#onItemRangeChanged(ItemHierarchy, int, int) */
+ public void notifyItemRangeChanged(int position, int itemCount) {
+ if (position < 0) {
+ Log.w(TAG, "notifyItemRangeChanged: Invalid position=" + position);
+ return;
+ }
+ if (itemCount < 0) {
+ Log.w(TAG, "notifyItemRangeChanged: Invalid itemCount=" + itemCount);
+ return;
+ }
+
+ for (Observer observer : observers) {
+ observer.onItemRangeChanged(this, position, itemCount);
+ }
+ }
+
+ /** @see Observer#onItemRangeInserted(ItemHierarchy, int, int) */
+ public void notifyItemRangeInserted(int position, int itemCount) {
+ if (position < 0) {
+ Log.w(TAG, "notifyItemRangeInserted: Invalid position=" + position);
+ return;
+ }
+ if (itemCount < 0) {
+ Log.w(TAG, "notifyItemRangeInserted: Invalid itemCount=" + itemCount);
+ return;
+ }
+
+ for (Observer observer : observers) {
+ observer.onItemRangeInserted(this, position, itemCount);
+ }
+ }
+
+ /** @see Observer#onItemRangeMoved(ItemHierarchy, int, int, int) */
+ public void notifyItemRangeMoved(int fromPosition, int toPosition, int itemCount) {
+ if (fromPosition < 0) {
+ Log.w(TAG, "notifyItemRangeMoved: Invalid fromPosition=" + fromPosition);
+ return;
+ }
+ if (toPosition < 0) {
+ Log.w(TAG, "notifyItemRangeMoved: Invalid toPosition=" + toPosition);
+ return;
+ }
+ if (itemCount < 0) {
+ Log.w(TAG, "notifyItemRangeMoved: Invalid itemCount=" + itemCount);
+ return;
+ }
+
+ for (Observer observer : observers) {
+ observer.onItemRangeMoved(this, fromPosition, toPosition, itemCount);
+ }
+ }
+
+ /** @see Observer#onItemRangeRemoved(ItemHierarchy, int, int) */
+ public void notifyItemRangeRemoved(int position, int itemCount) {
+ if (position < 0) {
+ Log.w(TAG, "notifyItemRangeInserted: Invalid position=" + position);
+ return;
+ }
+ if (itemCount < 0) {
+ Log.w(TAG, "notifyItemRangeInserted: Invalid itemCount=" + itemCount);
+ return;
+ }
+
+ for (Observer observer : observers) {
+ observer.onItemRangeRemoved(this, position, itemCount);
+ }
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/items/ButtonBarItem.java b/main/src/com/google/android/setupdesign/items/ButtonBarItem.java
new file mode 100644
index 0000000..f6b3f89
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/items/ButtonBarItem.java
@@ -0,0 +1,127 @@
+/*
+ * Copyright (C) 2016 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.google.android.setupdesign.items;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.View;
+import android.widget.Button;
+import android.widget.LinearLayout;
+import com.google.android.setupdesign.R;
+import java.util.ArrayList;
+
+/**
+ * A list item with one or more buttons, declared as {@link
+ * com.google.android.setupdesign.items.ButtonItem}.
+ *
+ * <p>Example usage:
+ *
+ * <pre>{@code
+ * &lt;ButtonBarItem&gt;
+ *
+ * &lt;ButtonItem
+ * android:id="@+id/skip_button"
+ * android:text="@string/skip_button_label /&gt;
+ *
+ * &lt;ButtonItem
+ * android:id="@+id/next_button"
+ * android:text="@string/next_button_label
+ * android:theme="@style/SuwButtonItem.Colored" /&gt;
+ *
+ * &lt;/ButtonBarItem&gt;
+ * }</pre>
+ */
+public class ButtonBarItem extends AbstractItem implements ItemInflater.ItemParent {
+
+ private final ArrayList<ButtonItem> buttons = new ArrayList<>();
+ private boolean visible = true;
+
+ public ButtonBarItem() {
+ super();
+ }
+
+ public ButtonBarItem(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @Override
+ public int getCount() {
+ return isVisible() ? 1 : 0;
+ }
+
+ @Override
+ public boolean isEnabled() {
+ // The children buttons are enabled and clickable, but the item itself is not
+ return false;
+ }
+
+ @Override
+ public int getLayoutResource() {
+ return R.layout.suw_items_button_bar;
+ }
+
+ public void setVisible(boolean visible) {
+ this.visible = visible;
+ }
+
+ public boolean isVisible() {
+ return visible;
+ }
+
+ @Override
+ public int getViewId() {
+ return getId();
+ }
+
+ @Override
+ public void onBindView(View view) {
+ // Note: The efficiency could be improved by trying to recycle the buttons created by
+ // ButtonItem
+ final LinearLayout layout = (LinearLayout) view;
+ layout.removeAllViews();
+
+ for (ButtonItem buttonItem : buttons) {
+ Button button = buttonItem.createButton(layout);
+ layout.addView(button);
+ }
+
+ view.setId(getViewId());
+ }
+
+ @Override
+ public void addChild(ItemHierarchy child) {
+ if (child instanceof ButtonItem) {
+ buttons.add((ButtonItem) child);
+ } else {
+ throw new UnsupportedOperationException("Cannot add non-button item to Button Bar");
+ }
+ }
+
+ @Override
+ public ItemHierarchy findItemById(int id) {
+ if (getId() == id) {
+ return this;
+ }
+ for (ButtonItem button : buttons) {
+ final ItemHierarchy item = button.findItemById(id);
+ if (item != null) {
+ return item;
+ }
+ }
+ return null;
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/items/ButtonItem.java b/main/src/com/google/android/setupdesign/items/ButtonItem.java
new file mode 100644
index 0000000..a205d7a
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/items/ButtonItem.java
@@ -0,0 +1,155 @@
+/*
+ * Copyright (C) 2016 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.google.android.setupdesign.items;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.util.AttributeSet;
+import android.view.ContextThemeWrapper;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Button;
+import com.google.android.setupdesign.R;
+
+/**
+ * Description of a button inside {@link com.google.android.setupdesign.items.ButtonBarItem}. This
+ * item will not be bound by the adapter, and must be a child of {@code ButtonBarItem}.
+ */
+public class ButtonItem extends AbstractItem implements View.OnClickListener {
+
+ public interface OnClickListener {
+ void onClick(ButtonItem item);
+ }
+
+ private boolean enabled = true;
+ private CharSequence text;
+ private int theme = R.style.SuwButtonItem;
+ private OnClickListener listener;
+
+ private Button button;
+
+ public ButtonItem() {
+ super();
+ }
+
+ public ButtonItem(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.SuwButtonItem);
+ enabled = a.getBoolean(R.styleable.SuwButtonItem_android_enabled, true);
+ text = a.getText(R.styleable.SuwButtonItem_android_text);
+ theme = a.getResourceId(R.styleable.SuwButtonItem_android_theme, R.style.SuwButtonItem);
+ a.recycle();
+ }
+
+ public void setOnClickListener(OnClickListener listener) {
+ this.listener = listener;
+ }
+
+ public void setText(CharSequence text) {
+ this.text = text;
+ }
+
+ public CharSequence getText() {
+ return text;
+ }
+
+ /**
+ * The theme to use for this button. This can be used to create button of a particular style (e.g.
+ * a colored or borderless button). Typically {@code android:buttonStyle} will be set in the theme
+ * to change the style applied by the button.
+ *
+ * @param theme Resource ID of the theme
+ */
+ public void setTheme(int theme) {
+ this.theme = theme;
+ button = null;
+ }
+
+ /** @return Resource ID of the theme used by this button. */
+ public int getTheme() {
+ return theme;
+ }
+
+ public void setEnabled(boolean enabled) {
+ this.enabled = enabled;
+ }
+
+ @Override
+ public int getCount() {
+ return 0;
+ }
+
+ @Override
+ public boolean isEnabled() {
+ return enabled;
+ }
+
+ @Override
+ public int getLayoutResource() {
+ return 0;
+ }
+
+ /** Do not use this since ButtonItem is not directly part of a list. */
+ @Override
+ public final void onBindView(View view) {
+ throw new UnsupportedOperationException("Cannot bind to ButtonItem's view");
+ }
+
+ /**
+ * Create a button according to this button item.
+ *
+ * @param parent The parent of the button, used to retrieve the theme and context for this button.
+ * @return A button that can be added to the parent.
+ */
+ protected Button createButton(ViewGroup parent) {
+ if (button == null) {
+ Context context = parent.getContext();
+ if (theme != 0) {
+ context = new ContextThemeWrapper(context, theme);
+ }
+ button = createButton(context);
+ button.setOnClickListener(this);
+ } else {
+ if (button.getParent() instanceof ViewGroup) {
+ // A view cannot be added to a different parent if one already exists. Remove this
+ // button from its parent before returning.
+ ((ViewGroup) button.getParent()).removeView(button);
+ }
+ }
+ button.setEnabled(enabled);
+ button.setText(text);
+ button.setId(getViewId());
+ return button;
+ }
+
+ @SuppressLint("InflateParams") // This is used similar to Button(Context), so it's OK to not
+ // specify the parent.
+ private Button createButton(Context context) {
+ // Inflate a single button from XML, so that when using support lib, it will take advantage
+ // of the injected layout inflater and give us AppCompatButton instead.
+ return (Button) LayoutInflater.from(context).inflate(R.layout.suw_button, null, false);
+ }
+
+ @Override
+ public void onClick(View v) {
+ if (listener != null) {
+ listener.onClick(this);
+ }
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/items/ExpandableSwitchItem.java b/main/src/com/google/android/setupdesign/items/ExpandableSwitchItem.java
new file mode 100644
index 0000000..ff9ae1f
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/items/ExpandableSwitchItem.java
@@ -0,0 +1,165 @@
+/*
+ * Copyright (C) 2017 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.google.android.setupdesign.items;
+
+import android.content.Context;
+import android.content.res.ColorStateList;
+import android.content.res.TypedArray;
+import android.graphics.PorterDuff.Mode;
+import android.graphics.drawable.Drawable;
+import android.os.Build.VERSION;
+import android.os.Build.VERSION_CODES;
+import android.util.AttributeSet;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.widget.CompoundButton.OnCheckedChangeListener;
+import android.widget.TextView;
+import com.google.android.setupdesign.R;
+import com.google.android.setupdesign.view.CheckableLinearLayout;
+
+/**
+ * A switch item which is divided into two parts: the start (left for LTR) side shows the title and
+ * summary, and when that is clicked, will expand to show a longer summary. The end (right for LTR)
+ * side is a switch which can be toggled by the user.
+ *
+ * <p>Note: It is highly recommended to use this item with recycler view rather than list view,
+ * because list view draws the touch ripple effect on top of the item, rather than letting the item
+ * handle it. Therefore you might see a double-ripple, one for the expandable area and one for the
+ * entire list item, when using this in list view.
+ */
+public class ExpandableSwitchItem extends SwitchItem
+ implements OnCheckedChangeListener, OnClickListener {
+
+ private CharSequence collapsedSummary;
+ private CharSequence expandedSummary;
+ private boolean isExpanded = false;
+
+ public ExpandableSwitchItem() {
+ super();
+ }
+
+ public ExpandableSwitchItem(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.SuwExpandableSwitchItem);
+ collapsedSummary = a.getText(R.styleable.SuwExpandableSwitchItem_suwCollapsedSummary);
+ expandedSummary = a.getText(R.styleable.SuwExpandableSwitchItem_suwExpandedSummary);
+ a.recycle();
+ }
+
+ @Override
+ protected int getDefaultLayoutResource() {
+ return R.layout.suw_items_expandable_switch;
+ }
+
+ @Override
+ public CharSequence getSummary() {
+ return isExpanded ? getExpandedSummary() : getCollapsedSummary();
+ }
+
+ /** @return True if the item is currently expanded. */
+ public boolean isExpanded() {
+ return isExpanded;
+ }
+
+ /** Sets whether the item should be expanded. */
+ public void setExpanded(boolean expanded) {
+ if (isExpanded == expanded) {
+ return;
+ }
+ isExpanded = expanded;
+ notifyItemChanged();
+ }
+
+ /** @return The summary shown when in collapsed state. */
+ public CharSequence getCollapsedSummary() {
+ return collapsedSummary;
+ }
+
+ /**
+ * Sets the summary text shown when the item is collapsed. Corresponds to the {@code
+ * app:suwCollapsedSummary} XML attribute.
+ */
+ public void setCollapsedSummary(CharSequence collapsedSummary) {
+ this.collapsedSummary = collapsedSummary;
+ if (!isExpanded()) {
+ notifyChanged();
+ }
+ }
+
+ /** @return The summary shown when in expanded state. */
+ public CharSequence getExpandedSummary() {
+ return expandedSummary;
+ }
+
+ /**
+ * Sets the summary text shown when the item is expanded. Corresponds to the {@code
+ * app:suwExpandedSummary} XML attribute.
+ */
+ public void setExpandedSummary(CharSequence expandedSummary) {
+ this.expandedSummary = expandedSummary;
+ if (isExpanded()) {
+ notifyChanged();
+ }
+ }
+
+ @Override
+ public void onBindView(View view) {
+ // TODO: If it is possible to detect, log a warning if this is being used with ListView.
+ super.onBindView(view);
+ View content = view.findViewById(R.id.suw_items_expandable_switch_content);
+ content.setOnClickListener(this);
+
+ if (content instanceof CheckableLinearLayout) {
+ ((CheckableLinearLayout) content).setChecked(isExpanded());
+ }
+
+ tintCompoundDrawables(view);
+
+ // Expandable switch item has focusability on the expandable layout on the left, and the
+ // switch on the right, but not the item itself.
+ view.setFocusable(false);
+ }
+
+ @Override
+ public void onClick(View v) {
+ setExpanded(!isExpanded());
+ }
+
+ // Tint the expand arrow with the text color
+ private void tintCompoundDrawables(View view) {
+ final TypedArray a =
+ view.getContext().obtainStyledAttributes(new int[] {android.R.attr.textColorPrimary});
+ final ColorStateList tintColor = a.getColorStateList(0);
+ a.recycle();
+
+ if (tintColor != null) {
+ TextView titleView = (TextView) view.findViewById(R.id.suw_items_title);
+ for (Drawable drawable : titleView.getCompoundDrawables()) {
+ if (drawable != null) {
+ drawable.setColorFilter(tintColor.getDefaultColor(), Mode.SRC_IN);
+ }
+ }
+ if (VERSION.SDK_INT >= VERSION_CODES.JELLY_BEAN_MR1) {
+ for (Drawable drawable : titleView.getCompoundDrawablesRelative()) {
+ if (drawable != null) {
+ drawable.setColorFilter(tintColor.getDefaultColor(), Mode.SRC_IN);
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/items/IItem.java b/main/src/com/google/android/setupdesign/items/IItem.java
new file mode 100644
index 0000000..72b9623
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/items/IItem.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright (C) 2015 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.google.android.setupdesign.items;
+
+import android.view.View;
+
+/** Representation of an item in an {@link ItemHierarchy}. */
+public interface IItem {
+
+ /**
+ * Get the Android resource ID for locating the layout for this item.
+ *
+ * @return Resource ID for the layout of this item. This layout will be used to inflate the View
+ * passed to {@link #onBindView(android.view.View)}.
+ */
+ int getLayoutResource();
+
+ /**
+ * Called by items framework to display the data specified by this item. This method should update
+ * {@code view} to reflect its data.
+ *
+ * @param view A view inflated from {@link #getLayoutResource()}, which should be updated to
+ * display data from this item. This view may be recycled from other items with the same
+ * layout resource.
+ */
+ void onBindView(View view);
+
+ /** @return True if this item is enabled. */
+ boolean isEnabled();
+}
diff --git a/main/src/com/google/android/setupdesign/items/Item.java b/main/src/com/google/android/setupdesign/items/Item.java
new file mode 100644
index 0000000..b5c99b0
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/items/Item.java
@@ -0,0 +1,175 @@
+/*
+ * Copyright (C) 2015 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.google.android.setupdesign.items;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.drawable.Drawable;
+import android.util.AttributeSet;
+import android.view.View;
+import android.widget.ImageView;
+import android.widget.TextView;
+import com.google.android.setupdesign.R;
+
+/**
+ * Definition of an item in an {@link ItemHierarchy}. An item is usually defined in XML and inflated
+ * using {@link ItemInflater}.
+ */
+public class Item extends AbstractItem {
+
+ private boolean enabled = true;
+ private Drawable icon;
+ private int layoutRes;
+ private CharSequence summary;
+ private CharSequence title;
+ private boolean visible = true;
+
+ public Item() {
+ super();
+ layoutRes = getDefaultLayoutResource();
+ }
+
+ public Item(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.SuwItem);
+ enabled = a.getBoolean(R.styleable.SuwItem_android_enabled, true);
+ icon = a.getDrawable(R.styleable.SuwItem_android_icon);
+ title = a.getText(R.styleable.SuwItem_android_title);
+ summary = a.getText(R.styleable.SuwItem_android_summary);
+ layoutRes = a.getResourceId(R.styleable.SuwItem_android_layout, getDefaultLayoutResource());
+ visible = a.getBoolean(R.styleable.SuwItem_android_visible, true);
+ a.recycle();
+ }
+
+ protected int getDefaultLayoutResource() {
+ return R.layout.suw_items_default;
+ }
+
+ public void setEnabled(boolean enabled) {
+ this.enabled = enabled;
+ notifyItemChanged();
+ }
+
+ @Override
+ public int getCount() {
+ return isVisible() ? 1 : 0;
+ }
+
+ @Override
+ public boolean isEnabled() {
+ return enabled;
+ }
+
+ public void setIcon(Drawable icon) {
+ this.icon = icon;
+ notifyItemChanged();
+ }
+
+ public Drawable getIcon() {
+ return icon;
+ }
+
+ public void setLayoutResource(int layoutResource) {
+ layoutRes = layoutResource;
+ notifyItemChanged();
+ }
+
+ @Override
+ public int getLayoutResource() {
+ return layoutRes;
+ }
+
+ public void setSummary(CharSequence summary) {
+ this.summary = summary;
+ notifyItemChanged();
+ }
+
+ public CharSequence getSummary() {
+ return summary;
+ }
+
+ public void setTitle(CharSequence title) {
+ this.title = title;
+ notifyItemChanged();
+ }
+
+ public CharSequence getTitle() {
+ return title;
+ }
+
+ public void setVisible(boolean visible) {
+ if (this.visible == visible) {
+ return;
+ }
+ this.visible = visible;
+ if (!visible) {
+ notifyItemRangeRemoved(0, 1);
+ } else {
+ notifyItemRangeInserted(0, 1);
+ }
+ }
+
+ public boolean isVisible() {
+ return visible;
+ }
+
+ @Override
+ public int getViewId() {
+ return getId();
+ }
+
+ @Override
+ public void onBindView(View view) {
+ TextView label = (TextView) view.findViewById(R.id.suw_items_title);
+ label.setText(getTitle());
+
+ TextView summaryView = (TextView) view.findViewById(R.id.suw_items_summary);
+ CharSequence summary = getSummary();
+ if (summary != null && summary.length() > 0) {
+ summaryView.setText(summary);
+ summaryView.setVisibility(View.VISIBLE);
+ } else {
+ summaryView.setVisibility(View.GONE);
+ }
+
+ final View iconContainer = view.findViewById(R.id.suw_items_icon_container);
+ final Drawable icon = getIcon();
+ if (icon != null) {
+ final ImageView iconView = (ImageView) view.findViewById(R.id.suw_items_icon);
+ // Set the image drawable to null before setting the state and level to avoid affecting
+ // any recycled drawable in the ImageView
+ iconView.setImageDrawable(null);
+ onMergeIconStateAndLevels(iconView, icon);
+ iconView.setImageDrawable(icon);
+ iconContainer.setVisibility(View.VISIBLE);
+ } else {
+ iconContainer.setVisibility(View.GONE);
+ }
+
+ view.setId(getViewId());
+ }
+
+ /**
+ * Copies state and level information from {@link #getIcon()} to the currently bound view's
+ * ImageView. Subclasses can override this method to change whats being copied from the icon to
+ * the ImageView.
+ */
+ protected void onMergeIconStateAndLevels(ImageView iconView, Drawable icon) {
+ iconView.setImageState(icon.getState(), false /* merge */);
+ iconView.setImageLevel(icon.getLevel());
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/items/ItemAdapter.java b/main/src/com/google/android/setupdesign/items/ItemAdapter.java
new file mode 100644
index 0000000..ac13402
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/items/ItemAdapter.java
@@ -0,0 +1,152 @@
+/*
+ * Copyright (C) 2015 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.google.android.setupdesign.items;
+
+import android.util.SparseIntArray;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.BaseAdapter;
+
+/**
+ * An adapter typically used with ListView to display an {@link
+ * com.google.android.setupdesign.items.ItemHierarchy}. The item hierarchy used to create this
+ * adapter can be inflated by {@link ItemInflater} from XML.
+ */
+public class ItemAdapter extends BaseAdapter implements ItemHierarchy.Observer {
+
+ private final ItemHierarchy itemHierarchy;
+ private final ViewTypes viewTypes = new ViewTypes();
+
+ public ItemAdapter(ItemHierarchy hierarchy) {
+ itemHierarchy = hierarchy;
+ itemHierarchy.registerObserver(this);
+ refreshViewTypes();
+ }
+
+ @Override
+ public int getCount() {
+ return itemHierarchy.getCount();
+ }
+
+ @Override
+ public IItem getItem(int position) {
+ return itemHierarchy.getItemAt(position);
+ }
+
+ @Override
+ public long getItemId(int position) {
+ return position;
+ }
+
+ @Override
+ public int getItemViewType(int position) {
+ IItem item = getItem(position);
+ int layoutRes = item.getLayoutResource();
+ return viewTypes.get(layoutRes);
+ }
+
+ @Override
+ public int getViewTypeCount() {
+ return viewTypes.size();
+ }
+
+ private void refreshViewTypes() {
+ for (int i = 0; i < getCount(); i++) {
+ IItem item = getItem(i);
+ viewTypes.add(item.getLayoutResource());
+ }
+ }
+
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ IItem item = getItem(position);
+ if (convertView == null) {
+ LayoutInflater inflater = LayoutInflater.from(parent.getContext());
+ convertView = inflater.inflate(item.getLayoutResource(), parent, false);
+ }
+ item.onBindView(convertView);
+ return convertView;
+ }
+
+ @Override
+ public void onChanged(ItemHierarchy hierarchy) {
+ refreshViewTypes();
+ notifyDataSetChanged();
+ }
+
+ @Override
+ public void onItemRangeChanged(ItemHierarchy itemHierarchy, int positionStart, int itemCount) {
+ onChanged(itemHierarchy);
+ }
+
+ @Override
+ public void onItemRangeInserted(ItemHierarchy itemHierarchy, int positionStart, int itemCount) {
+ onChanged(itemHierarchy);
+ }
+
+ @Override
+ public void onItemRangeMoved(
+ ItemHierarchy itemHierarchy, int fromPosition, int toPosition, int itemCount) {
+ onChanged(itemHierarchy);
+ }
+
+ @Override
+ public void onItemRangeRemoved(ItemHierarchy itemHierarchy, int positionStart, int itemCount) {
+ onChanged(itemHierarchy);
+ }
+
+ @Override
+ public boolean isEnabled(int position) {
+ return getItem(position).isEnabled();
+ }
+
+ public ItemHierarchy findItemById(int id) {
+ return itemHierarchy.findItemById(id);
+ }
+
+ public ItemHierarchy getRootItemHierarchy() {
+ return itemHierarchy;
+ }
+
+ /**
+ * A helper class to pack a sparse set of integers (e.g. resource IDs) to a contiguous list of
+ * integers (e.g. adapter positions), providing mapping to retrieve the original ID from a given
+ * position. This is used to pack the view types of the adapter into contiguous integers from a
+ * given layout resource.
+ */
+ private static class ViewTypes {
+ private final SparseIntArray positionMap = new SparseIntArray();
+ private int nextPosition = 0;
+
+ public int add(int id) {
+ if (positionMap.indexOfKey(id) < 0) {
+ positionMap.put(id, nextPosition);
+ nextPosition++;
+ }
+ return positionMap.get(id);
+ }
+
+ public int size() {
+ return positionMap.size();
+ }
+
+ public int get(int id) {
+ return positionMap.get(id);
+ }
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/items/ItemGroup.java b/main/src/com/google/android/setupdesign/items/ItemGroup.java
new file mode 100644
index 0000000..fb5d3a4
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/items/ItemGroup.java
@@ -0,0 +1,307 @@
+/*
+ * Copyright (C) 2015 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.google.android.setupdesign.items;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.util.SparseIntArray;
+import java.util.ArrayList;
+import java.util.List;
+
+public class ItemGroup extends AbstractItemHierarchy
+ implements ItemInflater.ItemParent, ItemHierarchy.Observer {
+
+ /* static section */
+
+ private static final String TAG = "ItemGroup";
+
+ /**
+ * Binary search for the closest value that's smaller than or equal to {@code value}, and return
+ * the corresponding key.
+ */
+ private static int binarySearch(SparseIntArray array, int value) {
+ final int size = array.size();
+ int lo = 0;
+ int hi = size - 1;
+
+ while (lo <= hi) {
+ final int mid = (lo + hi) >>> 1;
+ final int midVal = array.valueAt(mid);
+
+ if (midVal < value) {
+ lo = mid + 1;
+ } else if (midVal > value) {
+ hi = mid - 1;
+ } else {
+ return array.keyAt(mid); // value found
+ }
+ }
+ // Value not found. Return the last item before our search range, which is the closest
+ // value smaller than the value we are looking for.
+ return array.keyAt(lo - 1);
+ }
+
+ /**
+ * Same as {@link List#indexOf(Object)}, but using identity comparison rather than {@link
+ * Object#equals(Object)}.
+ */
+ private static <T> int identityIndexOf(List<T> list, T object) {
+ final int count = list.size();
+ for (int i = 0; i < count; i++) {
+ if (list.get(i) == object) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ /* non-static section */
+
+ private final List<ItemHierarchy> children = new ArrayList<>();
+
+ /**
+ * A mapping from the index of an item hierarchy in children, to the first position in which the
+ * corresponding child hierarchy represents. For example:
+ *
+ * <p>ItemHierarchy Item Item Position Index
+ *
+ * <p>0 [ Wi-Fi AP 1 ] 0 | Wi-Fi AP 2 | 1 | Wi-Fi AP 3 | 2 | Wi-Fi AP 4 | 3 [ Wi-Fi AP 5 ] 4
+ *
+ * <p>1 [ <Empty Item Hierarchy> ]
+ *
+ * <p>2 [ Use cellular data ] 5
+ *
+ * <p>3 [ Don't connect ] 6
+ *
+ * <p>For this example of Wi-Fi screen, the following mapping will be produced: [ 0 -> 0 | 2 -> 5
+ * | 3 -> 6 ]
+ *
+ * <p>Also note how ItemHierarchy index 1 is not present in the map, because it is empty.
+ *
+ * <p>ItemGroup uses this map to look for which ItemHierarchy an item at a given position belongs
+ * to.
+ */
+ private final SparseIntArray hierarchyStart = new SparseIntArray();
+
+ private int count = 0;
+ private boolean dirty = false;
+
+ public ItemGroup() {
+ super();
+ }
+
+ public ItemGroup(Context context, AttributeSet attrs) {
+ // Constructor for XML inflation
+ super(context, attrs);
+ }
+
+ /** Add a child hierarchy to this item group. */
+ @Override
+ public void addChild(ItemHierarchy child) {
+ dirty = true;
+ children.add(child);
+ child.registerObserver(this);
+
+ final int count = child.getCount();
+ if (count > 0) {
+ notifyItemRangeInserted(getChildPosition(child), count);
+ }
+ }
+
+ /**
+ * Remove a previously added child from this item group.
+ *
+ * @return True if there is a match for the child and it is removed. False if the child could not
+ * be found in our list of child hierarchies.
+ */
+ public boolean removeChild(ItemHierarchy child) {
+ final int childIndex = identityIndexOf(children, child);
+ final int childPosition = getChildPosition(childIndex);
+ dirty = true;
+ if (childIndex != -1) {
+ final int childCount = child.getCount();
+ children.remove(childIndex);
+ child.unregisterObserver(this);
+ if (childCount > 0) {
+ notifyItemRangeRemoved(childPosition, childCount);
+ }
+ return true;
+ }
+ return false;
+ }
+
+ /** Remove all children from this hierarchy. */
+ public void clear() {
+ if (children.isEmpty()) {
+ return;
+ }
+
+ final int numRemoved = getCount();
+
+ for (ItemHierarchy item : children) {
+ item.unregisterObserver(this);
+ }
+ dirty = true;
+ children.clear();
+ notifyItemRangeRemoved(0, numRemoved);
+ }
+
+ @Override
+ public int getCount() {
+ updateDataIfNeeded();
+ return count;
+ }
+
+ @Override
+ public IItem getItemAt(int position) {
+ int itemIndex = getItemIndex(position);
+ ItemHierarchy item = children.get(itemIndex);
+ int subpos = position - hierarchyStart.get(itemIndex);
+ return item.getItemAt(subpos);
+ }
+
+ @Override
+ public void onChanged(ItemHierarchy hierarchy) {
+ // Need to set dirty, because our children may have gotten more items.
+ dirty = true;
+ notifyChanged();
+ }
+
+ /**
+ * @return The "Item Position" of the given child, or -1 if the child is not found. If the given
+ * child is empty, position of the next visible item is returned.
+ */
+ private int getChildPosition(ItemHierarchy child) {
+ // Check the identity of the child rather than using .equals(), because here we want
+ // to find the index of the instance itself rather than something that equals to it.
+ return getChildPosition(identityIndexOf(children, child));
+ }
+
+ private int getChildPosition(int childIndex) {
+ updateDataIfNeeded();
+ if (childIndex != -1) {
+ int childPos = -1;
+ int childCount = children.size();
+ for (int i = childIndex; childPos < 0 && i < childCount; i++) {
+ // Find the position of the first visible child after childIndex. This is required
+ // when removing the last item from a nested ItemGroup.
+ childPos = hierarchyStart.get(i, -1);
+ }
+ if (childPos < 0) {
+ // If the last item in a group is being removed, there will be no visible item.
+ // In that case return the count instead, since that is where the item would have
+ // been if the child is not empty.
+ childPos = getCount();
+ }
+ return childPos;
+ }
+ return -1;
+ }
+
+ @Override
+ public void onItemRangeChanged(ItemHierarchy itemHierarchy, int positionStart, int itemCount) {
+ // No need to set dirty because onItemRangeChanged does not include any structural changes.
+ final int childPosition = getChildPosition(itemHierarchy);
+ if (childPosition >= 0) {
+ notifyItemRangeChanged(childPosition + positionStart, itemCount);
+ } else {
+ Log.e(TAG, "Unexpected child change " + itemHierarchy);
+ }
+ }
+
+ @Override
+ public void onItemRangeInserted(ItemHierarchy itemHierarchy, int positionStart, int itemCount) {
+ dirty = true;
+ final int childPosition = getChildPosition(itemHierarchy);
+ if (childPosition >= 0) {
+ notifyItemRangeInserted(childPosition + positionStart, itemCount);
+ } else {
+ Log.e(TAG, "Unexpected child insert " + itemHierarchy);
+ }
+ }
+
+ @Override
+ public void onItemRangeMoved(
+ ItemHierarchy itemHierarchy, int fromPosition, int toPosition, int itemCount) {
+ dirty = true;
+ final int childPosition = getChildPosition(itemHierarchy);
+ if (childPosition >= 0) {
+ notifyItemRangeMoved(childPosition + fromPosition, childPosition + toPosition, itemCount);
+ } else {
+ Log.e(TAG, "Unexpected child move " + itemHierarchy);
+ }
+ }
+
+ @Override
+ public void onItemRangeRemoved(ItemHierarchy itemHierarchy, int positionStart, int itemCount) {
+ dirty = true;
+ final int childPosition = getChildPosition(itemHierarchy);
+ if (childPosition >= 0) {
+ notifyItemRangeRemoved(childPosition + positionStart, itemCount);
+ } else {
+ Log.e(TAG, "Unexpected child remove " + itemHierarchy);
+ }
+ }
+
+ @Override
+ public ItemHierarchy findItemById(int id) {
+ if (id == getId()) {
+ return this;
+ }
+ for (ItemHierarchy child : children) {
+ ItemHierarchy childFindItem = child.findItemById(id);
+ if (childFindItem != null) {
+ return childFindItem;
+ }
+ }
+ return null;
+ }
+
+ /** If dirty, this method will recalculate the number of items and hierarchyStart. */
+ private void updateDataIfNeeded() {
+ if (dirty) {
+ count = 0;
+ hierarchyStart.clear();
+ for (int itemIndex = 0; itemIndex < children.size(); itemIndex++) {
+ ItemHierarchy item = children.get(itemIndex);
+ if (item.getCount() > 0) {
+ hierarchyStart.put(itemIndex, count);
+ }
+ count += item.getCount();
+ }
+ dirty = false;
+ }
+ }
+
+ /**
+ * Use binary search to locate the item hierarchy a position is contained in.
+ *
+ * @return Index of the item hierarchy which is responsible for the item at {@code position}.
+ */
+ private int getItemIndex(int position) {
+ updateDataIfNeeded();
+ if (position < 0 || position >= count) {
+ throw new IndexOutOfBoundsException("size=" + count + "; index=" + position);
+ }
+ int result = binarySearch(hierarchyStart, position);
+ if (result < 0) {
+ throw new IllegalStateException("Cannot have item start index < 0");
+ }
+ return result;
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/items/ItemHierarchy.java b/main/src/com/google/android/setupdesign/items/ItemHierarchy.java
new file mode 100644
index 0000000..b22e9ef
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/items/ItemHierarchy.java
@@ -0,0 +1,89 @@
+/*
+ * Copyright (C) 2015 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.google.android.setupdesign.items;
+
+/**
+ * Representation of zero or more items in a list. Each instance of ItemHierarchy should be capable
+ * of being wrapped in ItemAdapter and be displayed.
+ *
+ * <p>For example, {@link com.google.android.setupdesign.items.Item} is a representation of a single
+ * item, typically with data provided from XML. {@link
+ * com.google.android.setupdesign.items.ItemGroup} represents a list of child item hierarchies it
+ * contains, but itself does not do any display.
+ */
+public interface ItemHierarchy {
+
+ /**
+ * Observer for any changes in this hierarchy. If anything updated that causes this hierarchy to
+ * show different content, this observer should be called.
+ */
+ interface Observer {
+ /**
+ * Called when an underlying data update that can cause this hierarchy to show different content
+ * has occurred.
+ *
+ * <p>Note: This is a catch-all notification, but recycler view will have a harder time figuring
+ * out the animations for the change, and might even not animate the change at all.
+ */
+ void onChanged(ItemHierarchy itemHierarchy);
+
+ /**
+ * Called when an underlying data update that can cause changes that are local to the given
+ * items. This method indicates that there are no structural changes like inserting or removing
+ * items.
+ */
+ void onItemRangeChanged(ItemHierarchy itemHierarchy, int positionStart, int itemCount);
+
+ /** Called when items are inserted at the given position. */
+ void onItemRangeInserted(ItemHierarchy itemHierarchy, int positionStart, int itemCount);
+
+ /** Called when the given items are moved to a different position. */
+ void onItemRangeMoved(
+ ItemHierarchy itemHierarchy, int fromPosition, int toPosition, int itemCount);
+
+ /** Called when the given items are removed from the item hierarchy. */
+ void onItemRangeRemoved(ItemHierarchy itemHierarchy, int positionStart, int itemCount);
+ }
+
+ /** Register an observer to observe changes for this item hierarchy. */
+ void registerObserver(Observer observer);
+
+ /** Unregister a previously registered observer. */
+ void unregisterObserver(Observer observer);
+
+ /** @return the number of items this item hierarchy represent. */
+ int getCount();
+
+ /**
+ * Get the item at position.
+ *
+ * @param position An integer from 0 to {@link #getCount()}}, which indicates the position in this
+ * item hierarchy to get the child item.
+ * @return A representation of the item at {@code position}. Must not be {@code null}.
+ */
+ IItem getItemAt(int position);
+
+ /**
+ * Find an item hierarchy within this hierarchy which has the given ID. Or null if no match is
+ * found. This hierarchy will be returned if our ID matches. Same restrictions for Android
+ * resource IDs apply to this ID. In fact, typically this ID is a resource ID generated from XML.
+ *
+ * @param id An ID to search for in this item hierarchy.
+ * @return An ItemHierarchy which matches the given ID.
+ */
+ ItemHierarchy findItemById(int id);
+}
diff --git a/main/src/com/google/android/setupdesign/items/ItemInflater.java b/main/src/com/google/android/setupdesign/items/ItemInflater.java
new file mode 100644
index 0000000..e213598
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/items/ItemInflater.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright (C) 2015 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.google.android.setupdesign.items;
+
+import android.content.Context;
+
+/** Inflate {@link Item} hierarchies from XML files. */
+public class ItemInflater extends ReflectionInflater<ItemHierarchy> {
+
+ public interface ItemParent {
+ void addChild(ItemHierarchy child);
+ }
+
+ public ItemInflater(Context context) {
+ super(context);
+ setDefaultPackage(Item.class.getPackage().getName() + ".");
+ }
+
+ @Override
+ protected void onAddChildItem(ItemHierarchy parent, ItemHierarchy child) {
+ if (parent instanceof ItemParent) {
+ ((ItemParent) parent).addChild(child);
+ } else {
+ throw new IllegalArgumentException("Cannot add child item to " + parent);
+ }
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/items/ItemViewHolder.java b/main/src/com/google/android/setupdesign/items/ItemViewHolder.java
new file mode 100644
index 0000000..b293cfe
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/items/ItemViewHolder.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright (C) 2015 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.google.android.setupdesign.items;
+
+import androidx.recyclerview.widget.RecyclerView;
+import android.view.View;
+import com.google.android.setupdesign.DividerItemDecoration;
+
+class ItemViewHolder extends RecyclerView.ViewHolder
+ implements DividerItemDecoration.DividedViewHolder {
+
+ private boolean isEnabled;
+ private IItem item;
+
+ ItemViewHolder(View itemView) {
+ super(itemView);
+ }
+
+ @Override
+ public boolean isDividerAllowedAbove() {
+ return isEnabled;
+ }
+
+ @Override
+ public boolean isDividerAllowedBelow() {
+ return isEnabled;
+ }
+
+ public void setEnabled(boolean isEnabled) {
+ this.isEnabled = isEnabled;
+ itemView.setClickable(isEnabled);
+ itemView.setEnabled(isEnabled);
+ itemView.setFocusable(isEnabled);
+ }
+
+ public void setItem(IItem item) {
+ this.item = item;
+ }
+
+ public IItem getItem() {
+ return item;
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/items/RecyclerItemAdapter.java b/main/src/com/google/android/setupdesign/items/RecyclerItemAdapter.java
new file mode 100644
index 0000000..2e86fe0
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/items/RecyclerItemAdapter.java
@@ -0,0 +1,247 @@
+/*
+ * Copyright (C) 2015 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.google.android.setupdesign.items;
+
+import android.content.res.TypedArray;
+import android.graphics.Rect;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.LayerDrawable;
+import androidx.annotation.VisibleForTesting;
+import androidx.recyclerview.widget.RecyclerView;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import com.google.android.setupdesign.R;
+
+/**
+ * An adapter used with RecyclerView to display an {@link ItemHierarchy}. The item hierarchy used to
+ * create this adapter can be inflated by {@link com.google.android.setupdesign.items.ItemInflater}
+ * from XML.
+ */
+public class RecyclerItemAdapter extends RecyclerView.Adapter<ItemViewHolder>
+ implements ItemHierarchy.Observer {
+
+ private static final String TAG = "RecyclerItemAdapter";
+
+ /**
+ * A view tag set by {@link View#setTag(Object)}. If set on the root view of a layout, it will not
+ * create the default background for the list item. This means the item will not have ripple touch
+ * feedback by default.
+ */
+ public static final String TAG_NO_BACKGROUND = "noBackground";
+
+ /** Listener for item selection in this adapter. */
+ public interface OnItemSelectedListener {
+
+ /**
+ * Called when an item in this adapter is clicked.
+ *
+ * @param item The Item corresponding to the position being clicked.
+ */
+ void onItemSelected(IItem item);
+ }
+
+ private final ItemHierarchy itemHierarchy;
+ private OnItemSelectedListener listener;
+
+ public RecyclerItemAdapter(ItemHierarchy hierarchy) {
+ itemHierarchy = hierarchy;
+ itemHierarchy.registerObserver(this);
+ }
+
+ /**
+ * Gets the item at the given position.
+ *
+ * @see ItemHierarchy#getItemAt(int)
+ */
+ public IItem getItem(int position) {
+ return itemHierarchy.getItemAt(position);
+ }
+
+ @Override
+ public long getItemId(int position) {
+ IItem mItem = getItem(position);
+ if (mItem instanceof AbstractItem) {
+ final int id = ((AbstractItem) mItem).getId();
+ return id > 0 ? id : RecyclerView.NO_ID;
+ } else {
+ return RecyclerView.NO_ID;
+ }
+ }
+
+ @Override
+ public int getItemCount() {
+ return itemHierarchy.getCount();
+ }
+
+ @Override
+ public ItemViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
+ final LayoutInflater inflater = LayoutInflater.from(parent.getContext());
+ final View view = inflater.inflate(viewType, parent, false);
+ final ItemViewHolder viewHolder = new ItemViewHolder(view);
+
+ final Object viewTag = view.getTag();
+ if (!TAG_NO_BACKGROUND.equals(viewTag)) {
+ final TypedArray typedArray =
+ parent.getContext().obtainStyledAttributes(R.styleable.SuwRecyclerItemAdapter);
+ Drawable selectableItemBackground =
+ typedArray.getDrawable(
+ R.styleable.SuwRecyclerItemAdapter_android_selectableItemBackground);
+ if (selectableItemBackground == null) {
+ selectableItemBackground =
+ typedArray.getDrawable(R.styleable.SuwRecyclerItemAdapter_selectableItemBackground);
+ }
+
+ Drawable background = view.getBackground();
+ if (background == null) {
+ background =
+ typedArray.getDrawable(R.styleable.SuwRecyclerItemAdapter_android_colorBackground);
+ }
+
+ if (selectableItemBackground == null || background == null) {
+ Log.e(
+ TAG,
+ "Cannot resolve required attributes."
+ + " selectableItemBackground="
+ + selectableItemBackground
+ + " background="
+ + background);
+ } else {
+ final Drawable[] layers = {background, selectableItemBackground};
+ view.setBackgroundDrawable(new PatchedLayerDrawable(layers));
+ }
+
+ typedArray.recycle();
+ }
+
+ view.setOnClickListener(
+ new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ final IItem item = viewHolder.getItem();
+ if (listener != null && item != null && item.isEnabled()) {
+ listener.onItemSelected(item);
+ }
+ }
+ });
+
+ return viewHolder;
+ }
+
+ @Override
+ public void onBindViewHolder(ItemViewHolder holder, int position) {
+ final IItem item = getItem(position);
+ holder.setEnabled(item.isEnabled());
+ holder.setItem(item);
+ item.onBindView(holder.itemView);
+ }
+
+ @Override
+ public int getItemViewType(int position) {
+ // Use layout resource as item view type. RecyclerView item type does not have to be
+ // contiguous.
+ IItem item = getItem(position);
+ return item.getLayoutResource();
+ }
+
+ @Override
+ public void onChanged(ItemHierarchy hierarchy) {
+ notifyDataSetChanged();
+ }
+
+ @Override
+ public void onItemRangeChanged(ItemHierarchy itemHierarchy, int positionStart, int itemCount) {
+ notifyItemRangeChanged(positionStart, itemCount);
+ }
+
+ @Override
+ public void onItemRangeInserted(ItemHierarchy itemHierarchy, int positionStart, int itemCount) {
+ notifyItemRangeInserted(positionStart, itemCount);
+ }
+
+ @Override
+ public void onItemRangeMoved(
+ ItemHierarchy itemHierarchy, int fromPosition, int toPosition, int itemCount) {
+ // There is no notifyItemRangeMoved
+ // https://code.google.com/p/android/issues/detail?id=125984
+ if (itemCount == 1) {
+ notifyItemMoved(fromPosition, toPosition);
+ } else {
+ // If more than one, degenerate into the catch-all data set changed callback, since I'm
+ // not sure how recycler view handles multiple calls to notifyItemMoved (if the result
+ // is committed after every notification then naively calling
+ // notifyItemMoved(from + i, to + i) is wrong).
+ // Logging this in case this is a more common occurrence than expected.
+ Log.i(TAG, "onItemRangeMoved with more than one item");
+ notifyDataSetChanged();
+ }
+ }
+
+ @Override
+ public void onItemRangeRemoved(ItemHierarchy itemHierarchy, int positionStart, int itemCount) {
+ notifyItemRangeRemoved(positionStart, itemCount);
+ }
+
+ /**
+ * Find an item hierarchy within the root hierarchy.
+ *
+ * @see ItemHierarchy#findItemById(int)
+ */
+ public ItemHierarchy findItemById(int id) {
+ return itemHierarchy.findItemById(id);
+ }
+
+ /** Gets the root item hierarchy in this adapter. */
+ public ItemHierarchy getRootItemHierarchy() {
+ return itemHierarchy;
+ }
+
+ /**
+ * Sets the listener to listen for when user clicks on a item.
+ *
+ * @see OnItemSelectedListener
+ */
+ public void setOnItemSelectedListener(OnItemSelectedListener listener) {
+ this.listener = listener;
+ }
+
+ /**
+ * Before Lollipop, LayerDrawable always return true in getPadding, even if the children layers do
+ * not have any padding. Patch the implementation so that getPadding returns false if the padding
+ * is empty.
+ *
+ * <p>When getPadding is true, the padding of the view will be replaced by the padding of the
+ * drawable when {@link View#setBackgroundDrawable(Drawable)} is called. This patched class makes
+ * sure layer drawables without padding does not clear out original padding on the view.
+ */
+ @VisibleForTesting
+ static class PatchedLayerDrawable extends LayerDrawable {
+
+ /** {@inheritDoc} */
+ PatchedLayerDrawable(Drawable[] layers) {
+ super(layers);
+ }
+
+ @Override
+ public boolean getPadding(Rect padding) {
+ final boolean superHasPadding = super.getPadding(padding);
+ return superHasPadding
+ && !(padding.left == 0 && padding.top == 0 && padding.right == 0 && padding.bottom == 0);
+ }
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/items/ReflectionInflater.java b/main/src/com/google/android/setupdesign/items/ReflectionInflater.java
new file mode 100644
index 0000000..329d240
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/items/ReflectionInflater.java
@@ -0,0 +1,139 @@
+/*
+ * Copyright (C) 2017 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.google.android.setupdesign.items;
+
+import android.content.Context;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import android.util.AttributeSet;
+import android.view.InflateException;
+import java.lang.reflect.Constructor;
+import java.util.HashMap;
+
+/**
+ * An XML inflater that creates items by reading the tag as a class name, and constructs said class
+ * by invoking the 2-argument constructor {@code Constructor(Context, AttributeSet)} via reflection.
+ *
+ * <p>Optionally a "default package" can be specified so that for unqualified tag names (i.e. names
+ * that do not contain "."), the default package will be prefixed onto the tag.
+ *
+ * @param <T> The class where all instances (including child elements) belong to. If parent and
+ * child elements belong to different class hierarchies, it's OK to set this to {@link Object}.
+ */
+public abstract class ReflectionInflater<T> extends SimpleInflater<T> {
+
+ /* static section */
+
+ private static final Class<?>[] CONSTRUCTOR_SIGNATURE =
+ new Class<?>[] {Context.class, AttributeSet.class};
+
+ private static final HashMap<String, Constructor<?>> constructorMap = new HashMap<>();
+
+ /* non-static section */
+
+ // Array used to contain the constructor arguments (Context, AttributeSet), to avoid allocating
+ // a new array for creation of every item.
+ private final Object[] tempConstructorArgs = new Object[2];
+
+ @Nullable private String defaultPackage;
+
+ @NonNull private final Context context;
+
+ /**
+ * Create a new inflater instance associated with a particular Context.
+ *
+ * @param context The context used to resolve resource IDs. This context is also passed to the
+ * constructor of the items created as the first argument.
+ */
+ protected ReflectionInflater(@NonNull Context context) {
+ super(context.getResources());
+ this.context = context;
+ }
+
+ @NonNull
+ public Context getContext() {
+ return context;
+ }
+
+ /**
+ * Instantiate the class by name. This attempts to instantiate class of the given {@code name}
+ * found in this inflater's ClassLoader.
+ *
+ * @param tagName The full name of the class to be instantiated.
+ * @param attrs The XML attributes supplied for this instance.
+ * @return The newly instantiated item.
+ */
+ @NonNull
+ public final T createItem(String tagName, String prefix, AttributeSet attrs) {
+ String qualifiedName = tagName;
+ if (prefix != null && qualifiedName.indexOf('.') == -1) {
+ qualifiedName = prefix.concat(qualifiedName);
+ }
+ @SuppressWarnings("unchecked") // qualifiedName should correspond to a subclass of T
+ Constructor<? extends T> constructor =
+ (Constructor<? extends T>) constructorMap.get(qualifiedName);
+
+ try {
+ if (constructor == null) {
+ // Class not found in the cache, see if it's real, and try to add it
+ @SuppressWarnings("unchecked") // qualifiedName should correspond to a subclass of T
+ Class<? extends T> clazz =
+ (Class<? extends T>) context.getClassLoader().loadClass(qualifiedName);
+ constructor = clazz.getConstructor(CONSTRUCTOR_SIGNATURE);
+ constructor.setAccessible(true);
+ constructorMap.put(tagName, constructor);
+ }
+
+ tempConstructorArgs[0] = context;
+ tempConstructorArgs[1] = attrs;
+ final T item = constructor.newInstance(tempConstructorArgs);
+ tempConstructorArgs[0] = null;
+ tempConstructorArgs[1] = null;
+ return item;
+ } catch (Exception e) {
+ throw new InflateException(
+ attrs.getPositionDescription() + ": Error inflating class " + qualifiedName, e);
+ }
+ }
+
+ @Override
+ protected T onCreateItem(String tagName, AttributeSet attrs) {
+ return createItem(tagName, defaultPackage, attrs);
+ }
+
+ /**
+ * Sets the default package that will be searched for classes to construct for tag names that have
+ * no explicit package.
+ *
+ * @param defaultPackage The default package. This will be prepended to the tag name, so it should
+ * end with a period.
+ */
+ public void setDefaultPackage(@Nullable String defaultPackage) {
+ this.defaultPackage = defaultPackage;
+ }
+
+ /**
+ * Returns the default package, or null if it is not set.
+ *
+ * @see #setDefaultPackage(String)
+ * @return The default package.
+ */
+ @Nullable
+ public String getDefaultPackage() {
+ return defaultPackage;
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/items/SimpleInflater.java b/main/src/com/google/android/setupdesign/items/SimpleInflater.java
new file mode 100644
index 0000000..c7e370a
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/items/SimpleInflater.java
@@ -0,0 +1,190 @@
+/*
+ * Copyright (C) 2017 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.google.android.setupdesign.items;
+
+import android.content.res.Resources;
+import android.content.res.XmlResourceParser;
+import androidx.annotation.NonNull;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.util.Xml;
+import android.view.InflateException;
+import java.io.IOException;
+import org.xmlpull.v1.XmlPullParser;
+import org.xmlpull.v1.XmlPullParserException;
+
+/**
+ * A simple XML inflater, which takes care of moving the parser to the correct position. Subclasses
+ * need to implement {@link #onCreateItem(String, AttributeSet)} to create an object representation
+ * and {@link #onAddChildItem(Object, Object)} to attach a child tag to the parent tag.
+ *
+ * @param <T> The class where all instances (including child elements) belong to. If parent and
+ * child elements belong to different class hierarchies, it's OK to set this to {@link Object}.
+ */
+public abstract class SimpleInflater<T> {
+
+ private static final String TAG = "SimpleInflater";
+ private static final boolean DEBUG = false;
+
+ protected final Resources resources;
+
+ /**
+ * Create a new inflater instance associated with a particular Resources bundle.
+ *
+ * @param resources The Resources class used to resolve given resource IDs.
+ */
+ protected SimpleInflater(@NonNull Resources resources) {
+ this.resources = resources;
+ }
+
+ public Resources getResources() {
+ return resources;
+ }
+
+ /**
+ * Inflate a new hierarchy from the specified XML resource. Throws InflaterException if there is
+ * an error.
+ *
+ * @param resId ID for an XML resource to load (e.g. <code>R.xml.my_xml</code>)
+ * @return The root of the inflated hierarchy.
+ */
+ public T inflate(int resId) {
+ XmlResourceParser parser = getResources().getXml(resId);
+ try {
+ return inflate(parser);
+ } finally {
+ parser.close();
+ }
+ }
+
+ /**
+ * Inflate a new hierarchy from the specified XML node. Throws InflaterException if there is an
+ * error.
+ *
+ * <p><em><strong>Important</strong></em>&nbsp;&nbsp;&nbsp;For performance reasons, inflation
+ * relies heavily on pre-processing of XML files that is done at build time. Therefore, it is not
+ * currently possible to use inflater with an XmlPullParser over a plain XML file at runtime.
+ *
+ * @param parser XML dom node containing the description of the hierarchy.
+ * @return The root of the inflated hierarchy.
+ */
+ public T inflate(XmlPullParser parser) {
+ final AttributeSet attrs = Xml.asAttributeSet(parser);
+ T createdItem;
+
+ try {
+ // Look for the root node.
+ int type;
+ while ((type = parser.next()) != XmlPullParser.START_TAG
+ && type != XmlPullParser.END_DOCUMENT) {
+ // continue
+ }
+
+ if (type != XmlPullParser.START_TAG) {
+ throw new InflateException(parser.getPositionDescription() + ": No start tag found!");
+ }
+
+ createdItem = createItemFromTag(parser.getName(), attrs);
+
+ rInflate(parser, createdItem, attrs);
+ } catch (XmlPullParserException e) {
+ throw new InflateException(e.getMessage(), e);
+ } catch (IOException e) {
+ throw new InflateException(parser.getPositionDescription() + ": " + e.getMessage(), e);
+ }
+
+ return createdItem;
+ }
+
+ /**
+ * This routine is responsible for creating the correct subclass of item given the xml element
+ * name.
+ *
+ * @param tagName The XML tag name for the item to be created.
+ * @param attrs An AttributeSet of attributes to apply to the item.
+ * @return The item created.
+ */
+ protected abstract T onCreateItem(String tagName, AttributeSet attrs);
+
+ private T createItemFromTag(String name, AttributeSet attrs) {
+ try {
+ T item = onCreateItem(name, attrs);
+ if (DEBUG) {
+ Log.v(TAG, item + " created for <" + name + ">");
+ }
+ return item;
+ } catch (InflateException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new InflateException(
+ attrs.getPositionDescription() + ": Error inflating class " + name, e);
+ }
+ }
+
+ /**
+ * Recursive method used to descend down the xml hierarchy and instantiate items, instantiate
+ * their children, and then call onFinishInflate().
+ */
+ private void rInflate(XmlPullParser parser, T parent, final AttributeSet attrs)
+ throws XmlPullParserException, IOException {
+ final int depth = parser.getDepth();
+
+ int type;
+ while (((type = parser.next()) != XmlPullParser.END_TAG || parser.getDepth() > depth)
+ && type != XmlPullParser.END_DOCUMENT) {
+
+ if (type != XmlPullParser.START_TAG) {
+ continue;
+ }
+
+ if (onInterceptCreateItem(parser, parent, attrs)) {
+ continue;
+ }
+
+ String name = parser.getName();
+ T item = createItemFromTag(name, attrs);
+
+ onAddChildItem(parent, item);
+
+ rInflate(parser, item, attrs);
+ }
+ }
+
+ /**
+ * Whether item creation should be intercepted to perform custom handling on the parser rather
+ * than creating an object from it. This is used in rare cases where a tag doesn't correspond to
+ * creation of an object.
+ *
+ * <p>The parser will be pointing to the start of a tag, you must stop parsing and return when you
+ * reach the end of this element. That is, this method is responsible for parsing the element at
+ * the given position together with all of its child tags.
+ *
+ * <p>Note that parsing of the root tag cannot be intercepted.
+ *
+ * @param parser XML dom node containing the description of the hierarchy.
+ * @param parent The item that should be the parent of whatever you create.
+ * @param attrs An AttributeSet of attributes to apply to the item.
+ * @return True to continue parsing without calling {@link #onCreateItem(String, AttributeSet)},
+ * or false if this inflater should proceed to create an item.
+ */
+ protected boolean onInterceptCreateItem(XmlPullParser parser, T parent, AttributeSet attrs)
+ throws XmlPullParserException {
+ return false;
+ }
+
+ protected abstract void onAddChildItem(T parent, T child);
+}
diff --git a/main/src/com/google/android/setupdesign/items/SwitchItem.java b/main/src/com/google/android/setupdesign/items/SwitchItem.java
new file mode 100644
index 0000000..7174c0d
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/items/SwitchItem.java
@@ -0,0 +1,124 @@
+/*
+ * Copyright (C) 2015 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.google.android.setupdesign.items;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import androidx.appcompat.widget.SwitchCompat;
+import android.util.AttributeSet;
+import android.view.View;
+import android.widget.CompoundButton;
+import com.google.android.setupdesign.R;
+
+/**
+ * An item that is displayed with a switch, with methods to manipulate and listen to the checked
+ * state of the switch. Note that by default, only click on the switch will change the on-off state.
+ * To change the switch state when tapping on the text, use the click handlers of list view or
+ * RecyclerItemAdapter with {@link #toggle(View)}.
+ */
+public class SwitchItem extends Item implements CompoundButton.OnCheckedChangeListener {
+
+ /** Listener for check state changes of this switch item. */
+ public interface OnCheckedChangeListener {
+
+ /**
+ * Callback when checked state of a {@link SwitchItem} is changed.
+ *
+ * @see #setOnCheckedChangeListener(OnCheckedChangeListener)
+ */
+ void onCheckedChange(SwitchItem item, boolean isChecked);
+ }
+
+ private boolean checked = false;
+ private OnCheckedChangeListener listener;
+
+ /** Creates a default switch item. */
+ public SwitchItem() {
+ super();
+ }
+
+ /**
+ * Creates a switch item. This constructor is used for inflation from XML.
+ *
+ * @param context The context which this item is inflated in.
+ * @param attrs The XML attributes defined on the item.
+ */
+ public SwitchItem(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.SuwSwitchItem);
+ checked = a.getBoolean(R.styleable.SuwSwitchItem_android_checked, false);
+ a.recycle();
+ }
+
+ /** Sets whether this item should be checked. */
+ public void setChecked(boolean checked) {
+ if (this.checked != checked) {
+ this.checked = checked;
+ notifyItemChanged();
+ if (listener != null) {
+ listener.onCheckedChange(this, checked);
+ }
+ }
+ }
+
+ /** @return True if this switch item is currently checked. */
+ public boolean isChecked() {
+ return checked;
+ }
+
+ @Override
+ protected int getDefaultLayoutResource() {
+ return R.layout.suw_items_switch;
+ }
+
+ /**
+ * Toggle the checked state of the switch, without invalidating the entire item.
+ *
+ * @param view The root view of this item, typically from the argument of onItemClick.
+ */
+ public void toggle(View view) {
+ checked = !checked;
+ final SwitchCompat switchView = (SwitchCompat) view.findViewById(R.id.suw_items_switch);
+ switchView.setChecked(checked);
+ }
+
+ @Override
+ public void onBindView(View view) {
+ super.onBindView(view);
+ final SwitchCompat switchView = (SwitchCompat) view.findViewById(R.id.suw_items_switch);
+ switchView.setOnCheckedChangeListener(null);
+ switchView.setChecked(checked);
+ switchView.setOnCheckedChangeListener(this);
+ switchView.setEnabled(isEnabled());
+ }
+
+ /**
+ * Sets a listener to listen for changes in checked state. This listener is invoked in both user
+ * toggling the switch and calls to {@link #setChecked(boolean)}.
+ */
+ public void setOnCheckedChangeListener(OnCheckedChangeListener listener) {
+ this.listener = listener;
+ }
+
+ @Override
+ public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
+ checked = isChecked;
+ if (listener != null) {
+ listener.onCheckedChange(this, isChecked);
+ }
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/span/LinkSpan.java b/main/src/com/google/android/setupdesign/span/LinkSpan.java
new file mode 100644
index 0000000..7f1f02b
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/span/LinkSpan.java
@@ -0,0 +1,143 @@
+/*
+ * Copyright (C) 2016 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.google.android.setupdesign.span;
+
+import android.content.Context;
+import android.content.ContextWrapper;
+import android.os.Build;
+import androidx.annotation.Nullable;
+import android.text.Selection;
+import android.text.Spannable;
+import android.text.TextPaint;
+import android.text.style.ClickableSpan;
+import android.util.Log;
+import android.view.View;
+import android.widget.TextView;
+
+/**
+ * A clickable span that will listen for click events and send it back to the context. To use this
+ * class, implement {@link OnLinkClickListener} in your TextView, or use {@link
+ * com.google.android.setupdesign.view.RichTextView#setOnClickListener(View.OnClickListener)}.
+ *
+ * <p>Note on accessibility: For TalkBack to be able to traverse and interact with the links, you
+ * should use {@code LinkAccessibilityHelper} in your {@code TextView} subclass. Optionally you can
+ * also use {@code RichTextView}, which includes link support.
+ */
+public class LinkSpan extends ClickableSpan {
+
+ /*
+ * Implementation note: When the orientation changes, TextView retains a reference to this span
+ * instead of writing it to a parcel (ClickableSpan is not Parcelable). If this class has any
+ * reference to the containing Activity (i.e. the activity context, or any views in the
+ * activity), it will cause memory leak.
+ */
+
+ /* static section */
+
+ private static final String TAG = "LinkSpan";
+
+ /** @deprecated Use {@link OnLinkClickListener} */
+ @Deprecated
+ public interface OnClickListener {
+ void onClick(LinkSpan span);
+ }
+
+ /**
+ * Listener that is invoked when a link span is clicked. If the containing view of this span
+ * implements this interface, this will be invoked when the link is clicked.
+ */
+ public interface OnLinkClickListener {
+
+ /**
+ * Called when a link has been clicked.
+ *
+ * @param span The span that was clicked.
+ * @return True if the click was handled, stopping further propagation of the click event.
+ */
+ boolean onLinkClick(LinkSpan span);
+ }
+
+ /* non-static section */
+
+ private final String id;
+
+ public LinkSpan(String id) {
+ this.id = id;
+ }
+
+ @Override
+ public void onClick(View view) {
+ if (dispatchClick(view)) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
+ // Prevent the touch event from bubbling up to the parent views.
+ view.cancelPendingInputEvents();
+ }
+ } else {
+ Log.w(TAG, "Dropping click event. No listener attached.");
+ }
+ if (view instanceof TextView) {
+ // Remove the highlight effect when the click happens by clearing the selection
+ CharSequence text = ((TextView) view).getText();
+ if (text instanceof Spannable) {
+ Selection.setSelection((Spannable) text, 0);
+ }
+ }
+ }
+
+ private boolean dispatchClick(View view) {
+ boolean handled = false;
+ if (view instanceof OnLinkClickListener) {
+ handled = ((OnLinkClickListener) view).onLinkClick(this);
+ }
+ if (!handled) {
+ final OnClickListener listener = getLegacyListenerFromContext(view.getContext());
+ if (listener != null) {
+ listener.onClick(this);
+ handled = true;
+ }
+ }
+ return handled;
+ }
+
+ /** @deprecated Deprecated together with {@link OnClickListener} */
+ @Nullable
+ @Deprecated
+ private OnClickListener getLegacyListenerFromContext(@Nullable Context context) {
+ while (true) {
+ if (context instanceof OnClickListener) {
+ return (OnClickListener) context;
+ } else if (context instanceof ContextWrapper) {
+ // Unwrap any context wrapper, in base the base context implements onClickListener.
+ // ContextWrappers cannot have circular base contexts, so at some point this will
+ // reach the one of the other cases and return.
+ context = ((ContextWrapper) context).getBaseContext();
+ } else {
+ return null;
+ }
+ }
+ }
+
+ @Override
+ public void updateDrawState(TextPaint drawState) {
+ super.updateDrawState(drawState);
+ drawState.setUnderlineText(false);
+ }
+
+ public String getId() {
+ return id;
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/span/SpanHelper.java b/main/src/com/google/android/setupdesign/span/SpanHelper.java
new file mode 100644
index 0000000..50f2251
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/span/SpanHelper.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright (C) 2016 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.google.android.setupdesign.span;
+
+import android.text.Spannable;
+
+/**
+ * Contains helper methods for dealing with text spans, e.g. the ones in {@code android.text.style}.
+ */
+public class SpanHelper {
+
+ /**
+ * Adds {@code newSpans} at the same start and end indices as {@code oldSpan} and remove {@code
+ * oldSpan} from the {@code spannable}.
+ */
+ public static void replaceSpan(Spannable spannable, Object oldSpan, Object... newSpans) {
+ final int spanStart = spannable.getSpanStart(oldSpan);
+ final int spanEnd = spannable.getSpanEnd(oldSpan);
+ spannable.removeSpan(oldSpan);
+ for (Object newSpan : newSpans) {
+ spannable.setSpan(newSpan, spanStart, spanEnd, 0);
+ }
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/template/ButtonFooterMixin.java b/main/src/com/google/android/setupdesign/template/ButtonFooterMixin.java
new file mode 100644
index 0000000..7ee192d
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/template/ButtonFooterMixin.java
@@ -0,0 +1,170 @@
+/*
+ * Copyright (C) 2017 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.google.android.setupdesign.template;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.StringRes;
+import androidx.annotation.StyleRes;
+import android.view.ContextThemeWrapper;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewStub;
+import android.widget.Button;
+import android.widget.LinearLayout;
+import android.widget.LinearLayout.LayoutParams;
+import com.google.android.setupcompat.TemplateLayout;
+import com.google.android.setupcompat.template.Mixin;
+import com.google.android.setupdesign.R;
+
+/**
+ * A {@link Mixin} for managing buttons. By default, the button bar follows the GLIF design and
+ * expects that buttons on the start (left for LTR) are "secondary" borderless buttons, while
+ * buttons on the end (right for LTR) are "primary" accent-colored buttons.
+ */
+public class ButtonFooterMixin implements Mixin {
+
+ private final Context context;
+
+ @Nullable private final ViewStub footerStub;
+
+ private LinearLayout buttonContainer;
+
+ /**
+ * Create a mixin for managing buttons on the footer.
+ *
+ * @param layout The {@link TemplateLayout} containing this mixin.
+ */
+ public ButtonFooterMixin(TemplateLayout layout) {
+ context = layout.getContext();
+ footerStub = (ViewStub) layout.findManagedViewById(R.id.suc_layout_footer);
+ }
+
+ /**
+ * Add a button with the given text and style. Common style for GLIF are {@code
+ * SuwGlifButton.Primary} and {@code SuwGlifButton.Secondary}.
+ *
+ * @param text The label for the button.
+ * @param theme Theme resource to be used for this button. Since this is applied as a theme, the
+ * resource will typically apply {@code android:buttonStyle} so it will be applied to the
+ * button as a style as well.
+ * @return The button that was created.
+ */
+ public Button addButton(CharSequence text, @StyleRes int theme) {
+ Button button = createThemedButton(context, theme);
+ button.setText(text);
+ return addButton(button);
+ }
+
+ /**
+ * Add a button with the given text and style. Common style for GLIF are {@code
+ * SuwGlifButton.Primary} and {@code SuwGlifButton.Secondary}.
+ *
+ * @param text The label for the button.
+ * @param theme Theme resource to be used for this button. Since this is applied as a theme, the
+ * resource will typically apply {@code android:buttonStyle} so it will be applied to the
+ * button as a style as well.
+ * @return The button that was created.
+ */
+ public Button addButton(@StringRes int text, @StyleRes int theme) {
+ Button button = createThemedButton(context, theme);
+ button.setText(text);
+ return addButton(button);
+ }
+
+ /**
+ * Add a button to the footer.
+ *
+ * @param button The button to be added to the footer.
+ * @return The button that was added.
+ */
+ public Button addButton(Button button) {
+ final LinearLayout buttonContainer = ensureFooterInflated();
+ buttonContainer.addView(button);
+ return button;
+ }
+
+ /**
+ * Add a space to the footer. Spaces will share the remaining space of footer, so for example,
+ * [Button] [space] [Button] [space] [Button] will give you 3 buttons, left, center, and right
+ * aligned.
+ *
+ * @return The view that was used as space.
+ */
+ public View addSpace() {
+ final LinearLayout buttonContainer = ensureFooterInflated();
+ View space = new View(buttonContainer.getContext());
+ space.setLayoutParams(new LayoutParams(0, 0, 1.0f));
+ space.setVisibility(View.INVISIBLE);
+ buttonContainer.addView(space);
+ return space;
+ }
+
+ /**
+ * Remove a previously added button.
+ *
+ * @param button The button to be removed.
+ */
+ public void removeButton(Button button) {
+ if (buttonContainer != null) {
+ buttonContainer.removeView(button);
+ }
+ }
+
+ /**
+ * Remove a previously added space.
+ *
+ * @param space The space to be removed.
+ */
+ public void removeSpace(View space) {
+ if (buttonContainer != null) {
+ buttonContainer.removeView(space);
+ }
+ }
+
+ /**
+ * Remove all views, including spaces, from the footer. Note that if the footer container is
+ * already inflated, this will not remove the container itself.
+ */
+ public void removeAllViews() {
+ if (buttonContainer != null) {
+ buttonContainer.removeAllViews();
+ }
+ }
+
+ @NonNull
+ private LinearLayout ensureFooterInflated() {
+ if (buttonContainer == null) {
+ if (footerStub == null) {
+ throw new IllegalStateException("Footer stub is not found in this template");
+ }
+ footerStub.setLayoutResource(R.layout.suw_glif_footer_button_bar);
+ buttonContainer = (LinearLayout) footerStub.inflate();
+ }
+ return buttonContainer;
+ }
+
+ @SuppressLint("InflateParams")
+ private Button createThemedButton(Context context, @StyleRes int theme) {
+ // Inflate a single button from XML, which when using support lib, will take advantage of
+ // the injected layout inflater and give us AppCompatButton instead.
+ LayoutInflater inflater = LayoutInflater.from(new ContextThemeWrapper(context, theme));
+ return (Button) inflater.inflate(R.layout.suw_button, null, false);
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/template/ColoredHeaderMixin.java b/main/src/com/google/android/setupdesign/template/ColoredHeaderMixin.java
new file mode 100644
index 0000000..fb581ca
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/template/ColoredHeaderMixin.java
@@ -0,0 +1,70 @@
+/*
+ * Copyright (C) 2017 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.google.android.setupdesign.template;
+
+import android.content.res.ColorStateList;
+import android.content.res.TypedArray;
+import android.util.AttributeSet;
+import android.widget.TextView;
+import com.google.android.setupcompat.TemplateLayout;
+import com.google.android.setupdesign.R;
+
+/**
+ * A {@link Mixin} displaying a header text that can be set to different colors. This Mixin is
+ * registered to the template using HeaderMixin.class, and can be retrieved using: {@code
+ * (ColoredHeaderMixin) templateLayout.getMixin(HeaderMixin.class}.
+ */
+public class ColoredHeaderMixin extends HeaderMixin {
+
+ /** {@inheritDoc} */
+ public ColoredHeaderMixin(TemplateLayout layout, AttributeSet attrs, int defStyleAttr) {
+ super(layout, attrs, defStyleAttr);
+
+ final TypedArray a =
+ layout
+ .getContext()
+ .obtainStyledAttributes(attrs, R.styleable.SuwColoredHeaderMixin, defStyleAttr, 0);
+
+ // Set the header color
+ final ColorStateList headerColor =
+ a.getColorStateList(R.styleable.SuwColoredHeaderMixin_suwHeaderColor);
+ if (headerColor != null) {
+ setColor(headerColor);
+ }
+
+ a.recycle();
+ }
+
+ /**
+ * Sets the color of the header text. This can also be set via XML using {@code
+ * app:suwHeaderColor}.
+ *
+ * @param color The text color of the header.
+ */
+ public void setColor(ColorStateList color) {
+ final TextView titleView = getTextView();
+ if (titleView != null) {
+ titleView.setTextColor(color);
+ }
+ }
+
+ /** @return The current text color of the header. */
+ public ColorStateList getColor() {
+ final TextView titleView = getTextView();
+ return titleView != null ? titleView.getTextColors() : null;
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/template/HeaderMixin.java b/main/src/com/google/android/setupdesign/template/HeaderMixin.java
new file mode 100644
index 0000000..bde0667
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/template/HeaderMixin.java
@@ -0,0 +1,91 @@
+/*
+ * Copyright (C) 2017 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.google.android.setupdesign.template;
+
+import android.content.res.TypedArray;
+import androidx.annotation.AttrRes;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import android.util.AttributeSet;
+import android.widget.TextView;
+import com.google.android.setupcompat.TemplateLayout;
+import com.google.android.setupcompat.template.Mixin;
+import com.google.android.setupdesign.R;
+
+/** A {@link Mixin} for setting and getting the header text. */
+public class HeaderMixin implements Mixin {
+
+ private final TemplateLayout templateLayout;
+
+ /**
+ * @param layout The layout this Mixin belongs to.
+ * @param attrs XML attributes given to the layout.
+ * @param defStyleAttr The default style attribute as given to the constructor of the layout.
+ */
+ public HeaderMixin(
+ @NonNull TemplateLayout layout, @Nullable AttributeSet attrs, @AttrRes int defStyleAttr) {
+ templateLayout = layout;
+
+ final TypedArray a =
+ layout
+ .getContext()
+ .obtainStyledAttributes(attrs, R.styleable.SuwHeaderMixin, defStyleAttr, 0);
+
+ // Set the header text
+ final CharSequence headerText = a.getText(R.styleable.SuwHeaderMixin_suwHeaderText);
+ if (headerText != null) {
+ setText(headerText);
+ }
+
+ a.recycle();
+ }
+
+ /** @return The TextView displaying the header. */
+ public TextView getTextView() {
+ return (TextView) templateLayout.findManagedViewById(R.id.suw_layout_title);
+ }
+
+ /**
+ * Sets the header text. This can also be set via the XML attribute {@code app:suwHeaderText}.
+ *
+ * @param title The resource ID of the text to be set as header.
+ */
+ public void setText(int title) {
+ final TextView titleView = getTextView();
+ if (titleView != null) {
+ titleView.setText(title);
+ }
+ }
+
+ /**
+ * Sets the header text. This can also be set via the XML attribute {@code app:suwHeaderText}.
+ *
+ * @param title The text to be set as header.
+ */
+ public void setText(CharSequence title) {
+ final TextView titleView = getTextView();
+ if (titleView != null) {
+ titleView.setText(title);
+ }
+ }
+
+ /** @return The current header text. */
+ public CharSequence getText() {
+ final TextView titleView = getTextView();
+ return titleView != null ? titleView.getText() : null;
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/template/IconMixin.java b/main/src/com/google/android/setupdesign/template/IconMixin.java
new file mode 100644
index 0000000..7627132
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/template/IconMixin.java
@@ -0,0 +1,107 @@
+/*
+ * Copyright (C) 2017 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.google.android.setupdesign.template;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.drawable.Drawable;
+import androidx.annotation.DrawableRes;
+import android.util.AttributeSet;
+import android.view.View;
+import android.widget.ImageView;
+import com.google.android.setupcompat.TemplateLayout;
+import com.google.android.setupcompat.template.Mixin;
+import com.google.android.setupdesign.R;
+
+/** A {@link Mixin} for setting an icon on the template layout. */
+public class IconMixin implements Mixin {
+
+ private final TemplateLayout templateLayout;
+
+ /**
+ * @param layout The template layout that this Mixin is a part of.
+ * @param attrs XML attributes given to the layout.
+ * @param defStyleAttr The default style attribute as given to the constructor of the layout.
+ */
+ public IconMixin(TemplateLayout layout, AttributeSet attrs, int defStyleAttr) {
+ templateLayout = layout;
+ final Context context = layout.getContext();
+
+ final TypedArray a =
+ context.obtainStyledAttributes(attrs, R.styleable.SuwIconMixin, defStyleAttr, 0);
+
+ final @DrawableRes int icon = a.getResourceId(R.styleable.SuwIconMixin_android_icon, 0);
+ if (icon != 0) {
+ setIcon(icon);
+ }
+
+ a.recycle();
+ }
+
+ /**
+ * Sets the icon on this layout. The icon can also be set in XML using {@code android:icon}.
+ *
+ * @param icon A drawable icon.
+ */
+ public void setIcon(Drawable icon) {
+ final ImageView iconView = getView();
+ if (iconView != null) {
+ iconView.setImageDrawable(icon);
+ iconView.setVisibility(icon != null ? View.VISIBLE : View.GONE);
+ }
+ }
+
+ /**
+ * Sets the icon on this layout. The icon can also be set in XML using {@code android:icon}.
+ *
+ * @param icon A drawable icon resource.
+ */
+ public void setIcon(@DrawableRes int icon) {
+ final ImageView iconView = getView();
+ if (iconView != null) {
+ // Note: setImageResource on the ImageView is overridden in AppCompatImageView for
+ // support lib users, which enables vector drawable compat to work on versions pre-L.
+ iconView.setImageResource(icon);
+ iconView.setVisibility(icon != 0 ? View.VISIBLE : View.GONE);
+ }
+ }
+
+ /** @return The icon previously set in {@link #setIcon(Drawable)} or {@code android:icon} */
+ public Drawable getIcon() {
+ final ImageView iconView = getView();
+ return iconView != null ? iconView.getDrawable() : null;
+ }
+
+ /** Sets the content description of the icon view */
+ public void setContentDescription(CharSequence description) {
+ final ImageView iconView = getView();
+ if (iconView != null) {
+ iconView.setContentDescription(description);
+ }
+ }
+
+ /** @return The content description of the icon view */
+ public CharSequence getContentDescription() {
+ final ImageView iconView = getView();
+ return iconView != null ? iconView.getContentDescription() : null;
+ }
+
+ /** @return The ImageView responsible for displaying the icon. */
+ protected ImageView getView() {
+ return (ImageView) templateLayout.findManagedViewById(R.id.suw_layout_icon);
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/template/ListMixin.java b/main/src/com/google/android/setupdesign/template/ListMixin.java
new file mode 100644
index 0000000..99ca7c8
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/template/ListMixin.java
@@ -0,0 +1,206 @@
+/*
+ * Copyright (C) 2017 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.google.android.setupdesign.template;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.drawable.Drawable;
+import android.os.Build;
+import android.os.Build.VERSION_CODES;
+import androidx.annotation.AttrRes;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import android.util.AttributeSet;
+import android.view.View;
+import android.widget.HeaderViewListAdapter;
+import android.widget.ListAdapter;
+import android.widget.ListView;
+import com.google.android.setupcompat.TemplateLayout;
+import com.google.android.setupcompat.template.Mixin;
+import com.google.android.setupdesign.R;
+import com.google.android.setupdesign.items.ItemAdapter;
+import com.google.android.setupdesign.items.ItemGroup;
+import com.google.android.setupdesign.items.ItemInflater;
+import com.google.android.setupdesign.util.DrawableLayoutDirectionHelper;
+
+/** A {@link Mixin} for interacting with ListViews. */
+public class ListMixin implements Mixin {
+
+ private final TemplateLayout templateLayout;
+
+ @Nullable private ListView listView;
+
+ private Drawable divider;
+ private Drawable defaultDivider;
+
+ private int dividerInsetStart;
+ private int dividerInsetEnd;
+
+ /** @param layout The layout this mixin belongs to. */
+ public ListMixin(
+ @NonNull TemplateLayout layout, @Nullable AttributeSet attrs, @AttrRes int defStyleAttr) {
+ templateLayout = layout;
+
+ final Context context = layout.getContext();
+ final TypedArray a =
+ context.obtainStyledAttributes(attrs, R.styleable.SuwListMixin, defStyleAttr, 0);
+
+ final int entries = a.getResourceId(R.styleable.SuwListMixin_android_entries, 0);
+ if (entries != 0) {
+ final ItemGroup inflated = (ItemGroup) new ItemInflater(context).inflate(entries);
+ setAdapter(new ItemAdapter(inflated));
+ }
+ int dividerInset = a.getDimensionPixelSize(R.styleable.SuwListMixin_suwDividerInset, -1);
+ if (dividerInset != -1) {
+ setDividerInset(dividerInset);
+ } else {
+ int dividerInsetStart =
+ a.getDimensionPixelSize(R.styleable.SuwListMixin_suwDividerInsetStart, 0);
+ int dividerInsetEnd = a.getDimensionPixelSize(R.styleable.SuwListMixin_suwDividerInsetEnd, 0);
+ setDividerInsets(dividerInsetStart, dividerInsetEnd);
+ }
+ a.recycle();
+ }
+
+ /**
+ * @return The list view contained in the layout, as marked by {@code @android:id/list}. This will
+ * return {@code null} if the list doesn't exist in the layout.
+ */
+ public ListView getListView() {
+ return getListViewInternal();
+ }
+
+ // Client code can assume getListView() will not be null if they know their template contains
+ // the list, but this mixin cannot. Any usages of getListView in this mixin needs null checks.
+ @Nullable
+ private ListView getListViewInternal() {
+ if (listView == null) {
+ final View list = templateLayout.findManagedViewById(android.R.id.list);
+ if (list instanceof ListView) {
+ listView = (ListView) list;
+ }
+ }
+ return listView;
+ }
+
+ /**
+ * List mixin needs to update the dividers if the layout direction has changed. This method should
+ * be called when {@link View#onLayout(boolean, int, int, int, int)} of the template is called.
+ */
+ public void onLayout() {
+ if (divider == null) {
+ // Update divider in case layout direction has just been resolved
+ updateDivider();
+ }
+ }
+
+ /**
+ * Gets the adapter of the list view in this layout. If the adapter is a HeaderViewListAdapter,
+ * this method will unwrap it and return the underlying adapter.
+ *
+ * @return The adapter, or {@code null} if there is no list, or if the list has no adapter.
+ */
+ public ListAdapter getAdapter() {
+ final ListView listView = getListViewInternal();
+ if (listView != null) {
+ final ListAdapter adapter = listView.getAdapter();
+ if (adapter instanceof HeaderViewListAdapter) {
+ return ((HeaderViewListAdapter) adapter).getWrappedAdapter();
+ }
+ return adapter;
+ }
+ return null;
+ }
+
+ /** Sets the adapter on the list view in this layout. */
+ public void setAdapter(ListAdapter adapter) {
+ final ListView listView = getListViewInternal();
+ if (listView != null) {
+ listView.setAdapter(adapter);
+ }
+ }
+
+ /** @deprecated Use {@link #setDividerInsets(int, int)} instead. */
+ @Deprecated
+ public void setDividerInset(int inset) {
+ setDividerInsets(inset, 0);
+ }
+
+ /**
+ * Sets the start inset of the divider. This will use the default divider drawable set in the
+ * theme and apply insets to it.
+ *
+ * @param start The number of pixels to inset on the "start" side of the list divider. Typically
+ * this will be either {@code @dimen/suw_items_glif_icon_divider_inset} or
+ * {@code @dimen/suw_items_glif_text_divider_inset}.
+ * @param end The number of pixels to inset on the "end" side of the list divider.
+ */
+ public void setDividerInsets(int start, int end) {
+ dividerInsetStart = start;
+ dividerInsetEnd = end;
+ updateDivider();
+ }
+
+ /**
+ * @return The number of pixels inset on the start side of the divider.
+ * @deprecated This is the same as {@link #getDividerInsetStart()}. Use that instead.
+ */
+ @Deprecated
+ public int getDividerInset() {
+ return getDividerInsetStart();
+ }
+
+ /** @return The number of pixels inset on the start side of the divider. */
+ public int getDividerInsetStart() {
+ return dividerInsetStart;
+ }
+
+ /** @return The number of pixels inset on the end side of the divider. */
+ public int getDividerInsetEnd() {
+ return dividerInsetEnd;
+ }
+
+ private void updateDivider() {
+ final ListView listView = getListViewInternal();
+ if (listView == null) {
+ return;
+ }
+ boolean shouldUpdate = true;
+ if (Build.VERSION.SDK_INT >= VERSION_CODES.KITKAT) {
+ shouldUpdate = templateLayout.isLayoutDirectionResolved();
+ }
+ if (shouldUpdate) {
+ if (defaultDivider == null) {
+ defaultDivider = listView.getDivider();
+ }
+ divider =
+ DrawableLayoutDirectionHelper.createRelativeInsetDrawable(
+ defaultDivider,
+ dividerInsetStart /* start */,
+ 0 /* top */,
+ dividerInsetEnd /* end */,
+ 0 /* bottom */,
+ templateLayout);
+ listView.setDivider(divider);
+ }
+ }
+
+ /** @return The drawable used as the divider. */
+ public Drawable getDivider() {
+ return divider;
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/template/ListViewScrollHandlingDelegate.java b/main/src/com/google/android/setupdesign/template/ListViewScrollHandlingDelegate.java
new file mode 100644
index 0000000..2040c6b
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/template/ListViewScrollHandlingDelegate.java
@@ -0,0 +1,82 @@
+/*
+ * Copyright (C) 2017 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.google.android.setupdesign.template;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import android.util.Log;
+import android.widget.AbsListView;
+import android.widget.ListAdapter;
+import android.widget.ListView;
+import com.google.android.setupdesign.template.RequireScrollMixin.ScrollHandlingDelegate;
+
+/**
+ * {@link ScrollHandlingDelegate} which analyzes scroll events from {@link ListView} and notifies
+ * {@link RequireScrollMixin} about scrollability changes.
+ */
+public class ListViewScrollHandlingDelegate
+ implements ScrollHandlingDelegate, AbsListView.OnScrollListener {
+
+ private static final String TAG = "ListViewDelegate";
+
+ private static final int SCROLL_DURATION = 500;
+
+ @NonNull private final RequireScrollMixin requireScrollMixin;
+
+ @Nullable private final ListView listView;
+
+ public ListViewScrollHandlingDelegate(
+ @NonNull RequireScrollMixin requireScrollMixin, @Nullable ListView listView) {
+ this.requireScrollMixin = requireScrollMixin;
+ this.listView = listView;
+ }
+
+ @Override
+ public void startListening() {
+ if (listView != null) {
+ listView.setOnScrollListener(this);
+
+ final ListAdapter adapter = listView.getAdapter();
+ if (listView.getLastVisiblePosition() < adapter.getCount()) {
+ requireScrollMixin.notifyScrollabilityChange(true);
+ }
+ } else {
+ Log.w(TAG, "Cannot require scroll. List view is null");
+ }
+ }
+
+ @Override
+ public void pageScrollDown() {
+ if (listView != null) {
+ final int height = listView.getHeight();
+ listView.smoothScrollBy(height, SCROLL_DURATION);
+ }
+ }
+
+ @Override
+ public void onScrollStateChanged(AbsListView view, int scrollState) {}
+
+ @Override
+ public void onScroll(
+ AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
+ if (firstVisibleItem + visibleItemCount >= totalItemCount) {
+ requireScrollMixin.notifyScrollabilityChange(false);
+ } else {
+ requireScrollMixin.notifyScrollabilityChange(true);
+ }
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/template/NavigationBarMixin.java b/main/src/com/google/android/setupdesign/template/NavigationBarMixin.java
new file mode 100644
index 0000000..f9e29ed
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/template/NavigationBarMixin.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright (C) 2017 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.google.android.setupdesign.template;
+
+import android.view.View;
+import com.google.android.setupcompat.TemplateLayout;
+import com.google.android.setupcompat.template.Mixin;
+import com.google.android.setupdesign.R;
+import com.google.android.setupdesign.view.NavigationBar;
+import com.google.android.setupdesign.view.NavigationBar.NavigationBarListener;
+
+/** A {@link Mixin} for interacting with a {@link NavigationBar}. */
+public class NavigationBarMixin implements Mixin {
+
+ private final TemplateLayout templateLayout;
+
+ /** @param layout The layout this mixin belongs to. */
+ public NavigationBarMixin(TemplateLayout layout) {
+ templateLayout = layout;
+ }
+
+ /**
+ * @return The navigation bar instance in the layout, or null if the layout does not have a
+ * navigation bar.
+ */
+ public NavigationBar getNavigationBar() {
+ final View view = templateLayout.findManagedViewById(R.id.suw_layout_navigation_bar);
+ return view instanceof NavigationBar ? (NavigationBar) view : null;
+ }
+
+ /**
+ * Sets the label of the next button.
+ *
+ * @param text Label of the next button.
+ */
+ public void setNextButtonText(int text) {
+ getNavigationBar().getNextButton().setText(text);
+ }
+
+ /**
+ * Sets the label of the next button.
+ *
+ * @param text Label of the next button.
+ */
+ public void setNextButtonText(CharSequence text) {
+ getNavigationBar().getNextButton().setText(text);
+ }
+
+ /** @return The current label of the next button. */
+ public CharSequence getNextButtonText() {
+ return getNavigationBar().getNextButton().getText();
+ }
+
+ /**
+ * Sets the listener to handle back and next button clicks in the navigation bar.
+ *
+ * @see NavigationBar#setNavigationBarListener(NavigationBarListener)
+ * @see NavigationBarListener
+ */
+ public void setNavigationBarListener(NavigationBarListener listener) {
+ getNavigationBar().setNavigationBarListener(listener);
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/template/ProgressBarMixin.java b/main/src/com/google/android/setupdesign/template/ProgressBarMixin.java
new file mode 100644
index 0000000..61b9e86
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/template/ProgressBarMixin.java
@@ -0,0 +1,128 @@
+/*
+ * Copyright (C) 2017 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.google.android.setupdesign.template;
+
+import android.content.res.ColorStateList;
+import android.os.Build;
+import android.os.Build.VERSION_CODES;
+import androidx.annotation.Nullable;
+import android.view.View;
+import android.view.ViewStub;
+import android.widget.ProgressBar;
+import com.google.android.setupcompat.TemplateLayout;
+import com.google.android.setupcompat.template.Mixin;
+import com.google.android.setupdesign.R;
+
+/** A {@link Mixin} for showing a progress bar. */
+public class ProgressBarMixin implements Mixin {
+
+ private final TemplateLayout templateLayout;
+
+ @Nullable private ColorStateList color;
+
+ /** @param layout The layout this mixin belongs to. */
+ public ProgressBarMixin(TemplateLayout layout) {
+ templateLayout = layout;
+ }
+
+ /** @return True if the progress bar is currently shown. */
+ public boolean isShown() {
+ final View progressBar = templateLayout.findManagedViewById(R.id.suw_layout_progress);
+ return progressBar != null && progressBar.getVisibility() == View.VISIBLE;
+ }
+
+ /**
+ * Sets whether the progress bar is shown. If the progress bar has not been inflated from the
+ * stub, this method will inflate the progress bar.
+ *
+ * @param shown True to show the progress bar, false to hide it.
+ */
+ public void setShown(boolean shown) {
+ if (shown) {
+ View progressBar = getProgressBar();
+ if (progressBar != null) {
+ progressBar.setVisibility(View.VISIBLE);
+ }
+ } else {
+ View progressBar = peekProgressBar();
+ if (progressBar != null) {
+ progressBar.setVisibility(View.GONE);
+ }
+ }
+ }
+
+ /**
+ * Gets the progress bar in the layout. If the progress bar has not been used before, it will be
+ * installed (i.e. inflated from its view stub).
+ *
+ * @return The progress bar of this layout. May be null only if the template used doesn't have a
+ * progress bar built-in.
+ */
+ private ProgressBar getProgressBar() {
+ final View progressBar = peekProgressBar();
+ if (progressBar == null) {
+ final ViewStub progressBarStub =
+ (ViewStub) templateLayout.findManagedViewById(R.id.suw_layout_progress_stub);
+ if (progressBarStub != null) {
+ progressBarStub.inflate();
+ }
+ setColor(color);
+ }
+ return peekProgressBar();
+ }
+
+ /**
+ * Gets the progress bar in the layout only if it has been installed. {@link #setShown(boolean)}
+ * should be called before this to ensure the progress bar is set up correctly.
+ *
+ * @return The progress bar of this layout, or null if the progress bar is not installed. The null
+ * case can happen either if {@link #setShown(boolean)} with true was not called before this,
+ * or if the template does not contain a progress bar.
+ */
+ public ProgressBar peekProgressBar() {
+ return (ProgressBar) templateLayout.findManagedViewById(R.id.suw_layout_progress);
+ }
+
+ /** Sets the color of the indeterminate progress bar. This method is a no-op on SDK < 21. */
+ public void setColor(@Nullable ColorStateList color) {
+ this.color = color;
+ if (Build.VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) {
+ final ProgressBar bar = peekProgressBar();
+ if (bar != null) {
+ bar.setIndeterminateTintList(color);
+ if (Build.VERSION.SDK_INT >= VERSION_CODES.M || color != null) {
+ // There is a bug in Lollipop where setting the progress tint color to null
+ // will crash with "java.lang.NullPointerException: Attempt to invoke virtual
+ // method 'int android.graphics.Paint.getAlpha()' on a null object reference"
+ // at android.graphics.drawable.NinePatchDrawable.draw(:250)
+ // The bug doesn't affect ProgressBar on M because it uses ShapeDrawable instead
+ // of NinePatchDrawable. (commit 6a8253fdc9f4574c28b4beeeed90580ffc93734a)
+ bar.setProgressBackgroundTintList(color);
+ }
+ }
+ }
+ }
+
+ /**
+ * @return The color previously set in {@link #setColor(ColorStateList)}, or null if the color is
+ * not set. In case of null, the color of the progress bar will be inherited from the theme.
+ */
+ @Nullable
+ public ColorStateList getColor() {
+ return color;
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/template/RecyclerMixin.java b/main/src/com/google/android/setupdesign/template/RecyclerMixin.java
new file mode 100644
index 0000000..b1809e8
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/template/RecyclerMixin.java
@@ -0,0 +1,257 @@
+/*
+ * Copyright (C) 2017 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.google.android.setupdesign.template;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.drawable.Drawable;
+import android.os.Build;
+import android.os.Build.VERSION_CODES;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+import androidx.recyclerview.widget.RecyclerView.Adapter;
+import androidx.recyclerview.widget.RecyclerView.ViewHolder;
+import android.util.AttributeSet;
+import android.view.View;
+import com.google.android.setupcompat.TemplateLayout;
+import com.google.android.setupcompat.template.Mixin;
+import com.google.android.setupdesign.DividerItemDecoration;
+import com.google.android.setupdesign.R;
+import com.google.android.setupdesign.items.ItemHierarchy;
+import com.google.android.setupdesign.items.ItemInflater;
+import com.google.android.setupdesign.items.RecyclerItemAdapter;
+import com.google.android.setupdesign.util.DrawableLayoutDirectionHelper;
+import com.google.android.setupdesign.view.HeaderRecyclerView;
+import com.google.android.setupdesign.view.HeaderRecyclerView.HeaderAdapter;
+
+/**
+ * A {@link Mixin} for interacting with templates with recycler views. This mixin constructor takes
+ * the instance of the recycler view to allow it to be instantiated dynamically, as in the case for
+ * preference fragments.
+ *
+ * <p>Unlike typical mixins, this mixin is designed to be created in onTemplateInflated, which is
+ * called by the super constructor, and then parse the XML attributes later in the constructor.
+ */
+public class RecyclerMixin implements Mixin {
+
+ private final TemplateLayout templateLayout;
+
+ @NonNull private final RecyclerView recyclerView;
+
+ @Nullable private View header;
+
+ @NonNull private DividerItemDecoration dividerDecoration;
+
+ private Drawable defaultDivider;
+ private Drawable divider;
+
+ private int dividerInsetStart;
+ private int dividerInsetEnd;
+
+ /**
+ * Creates the RecyclerMixin. Unlike typical mixins which are created in the constructor, this
+ * mixin should be called in {@link TemplateLayout#onTemplateInflated()}, which is called by the
+ * super constructor, because the recycler view and the header needs to be made available before
+ * other mixins from the super class.
+ *
+ * @param layout The layout this mixin belongs to.
+ */
+ public RecyclerMixin(@NonNull TemplateLayout layout, @NonNull RecyclerView recyclerView) {
+ templateLayout = layout;
+
+ dividerDecoration = new DividerItemDecoration(templateLayout.getContext());
+
+ // The recycler view needs to be available
+ this.recyclerView = recyclerView;
+ this.recyclerView.setLayoutManager(new LinearLayoutManager(templateLayout.getContext()));
+
+ if (recyclerView instanceof HeaderRecyclerView) {
+ header = ((HeaderRecyclerView) recyclerView).getHeader();
+ }
+
+ this.recyclerView.addItemDecoration(dividerDecoration);
+ }
+
+ /**
+ * Parse XML attributes and configures this mixin and the recycler view accordingly. This should
+ * be called from the constructor of the layout.
+ *
+ * @param attrs The {@link AttributeSet} as passed into the constructor. Can be null if the layout
+ * was not created from XML.
+ * @param defStyleAttr The default style attribute as passed into the layout constructor. Can be 0
+ * if it is not needed.
+ */
+ public void parseAttributes(@Nullable AttributeSet attrs, int defStyleAttr) {
+ final Context context = templateLayout.getContext();
+ final TypedArray a =
+ context.obtainStyledAttributes(attrs, R.styleable.SuwRecyclerMixin, defStyleAttr, 0);
+
+ final int entries = a.getResourceId(R.styleable.SuwRecyclerMixin_android_entries, 0);
+ if (entries != 0) {
+ final ItemHierarchy inflated = new ItemInflater(context).inflate(entries);
+ final RecyclerItemAdapter adapter = new RecyclerItemAdapter(inflated);
+ adapter.setHasStableIds(a.getBoolean(R.styleable.SuwRecyclerMixin_suwHasStableIds, false));
+ setAdapter(adapter);
+ }
+ int dividerInset = a.getDimensionPixelSize(R.styleable.SuwRecyclerMixin_suwDividerInset, -1);
+ if (dividerInset != -1) {
+ setDividerInset(dividerInset);
+ } else {
+ int dividerInsetStart =
+ a.getDimensionPixelSize(R.styleable.SuwRecyclerMixin_suwDividerInsetStart, 0);
+ int dividerInsetEnd =
+ a.getDimensionPixelSize(R.styleable.SuwRecyclerMixin_suwDividerInsetEnd, 0);
+ setDividerInsets(dividerInsetStart, dividerInsetEnd);
+ }
+
+ a.recycle();
+ }
+
+ /**
+ * @return The recycler view contained in the layout, as marked by {@code @id/suw_recycler_view}.
+ * This will return {@code null} if the recycler view doesn't exist in the layout.
+ */
+ @SuppressWarnings("NullableProblems") // If clients guarantee that the template has a recycler
+ // view, and call this after the template is inflated,
+ // this will not return null.
+ public RecyclerView getRecyclerView() {
+ return recyclerView;
+ }
+
+ /**
+ * Gets the header view of the recycler layout. This is useful for other mixins if they need to
+ * access views within the header, usually via {@link TemplateLayout#findManagedViewById(int)}.
+ */
+ @SuppressWarnings("NullableProblems") // If clients guarantee that the template has a header,
+ // this call will not return null.
+ public View getHeader() {
+ return header;
+ }
+
+ /**
+ * Recycler mixin needs to update the dividers if the layout direction has changed. This method
+ * should be called when {@link View#onLayout(boolean, int, int, int, int)} of the template is
+ * called.
+ */
+ public void onLayout() {
+ if (divider == null) {
+ // Update divider in case layout direction has just been resolved
+ updateDivider();
+ }
+ }
+
+ /**
+ * Gets the adapter of the recycler view in this layout. If the adapter includes a header, this
+ * method will unwrap it and return the underlying adapter.
+ *
+ * @return The adapter, or {@code null} if the recycler view has no adapter.
+ */
+ public Adapter<? extends ViewHolder> getAdapter() {
+ @SuppressWarnings("unchecked") // RecyclerView.getAdapter returns raw type :(
+ final RecyclerView.Adapter<? extends ViewHolder> adapter = recyclerView.getAdapter();
+ if (adapter instanceof HeaderAdapter) {
+ return ((HeaderAdapter<? extends ViewHolder>) adapter).getWrappedAdapter();
+ }
+ return adapter;
+ }
+
+ /** Sets the adapter on the recycler view in this layout. */
+ public void setAdapter(Adapter<? extends ViewHolder> adapter) {
+ recyclerView.setAdapter(adapter);
+ }
+
+ /** @deprecated Use {@link #setDividerInsets(int, int)} instead. */
+ @Deprecated
+ public void setDividerInset(int inset) {
+ setDividerInsets(inset, 0);
+ }
+
+ /**
+ * Sets the start inset of the divider. This will use the default divider drawable set in the
+ * theme and apply insets to it.
+ *
+ * @param start The number of pixels to inset on the "start" side of the list divider. Typically
+ * this will be either {@code @dimen/suw_items_glif_icon_divider_inset} or
+ * {@code @dimen/suw_items_glif_text_divider_inset}.
+ * @param end The number of pixels to inset on the "end" side of the list divider.
+ */
+ public void setDividerInsets(int start, int end) {
+ dividerInsetStart = start;
+ dividerInsetEnd = end;
+ updateDivider();
+ }
+
+ /**
+ * @return The number of pixels inset on the start side of the divider.
+ * @deprecated This is the same as {@link #getDividerInsetStart()}. Use that instead.
+ */
+ @Deprecated
+ public int getDividerInset() {
+ return getDividerInsetStart();
+ }
+
+ /** @return The number of pixels inset on the start side of the divider. */
+ public int getDividerInsetStart() {
+ return dividerInsetStart;
+ }
+
+ /** @return The number of pixels inset on the end side of the divider. */
+ public int getDividerInsetEnd() {
+ return dividerInsetEnd;
+ }
+
+ private void updateDivider() {
+ boolean shouldUpdate = true;
+ if (Build.VERSION.SDK_INT >= VERSION_CODES.KITKAT) {
+ shouldUpdate = templateLayout.isLayoutDirectionResolved();
+ }
+ if (shouldUpdate) {
+ if (defaultDivider == null) {
+ defaultDivider = dividerDecoration.getDivider();
+ }
+ divider =
+ DrawableLayoutDirectionHelper.createRelativeInsetDrawable(
+ defaultDivider,
+ dividerInsetStart /* start */,
+ 0 /* top */,
+ dividerInsetEnd /* end */,
+ 0 /* bottom */,
+ templateLayout);
+ dividerDecoration.setDivider(divider);
+ }
+ }
+
+ /** @return The drawable used as the divider. */
+ public Drawable getDivider() {
+ return divider;
+ }
+
+ /**
+ * Sets the divider item decoration directly. This is a low level method which should be used only
+ * if custom divider behavior is needed, for example if the divider should be shown / hidden in
+ * some specific cases for view holders that cannot implement {@link
+ * com.google.android.setupdesign.DividerItemDecoration.DividedViewHolder}.
+ */
+ public void setDividerItemDecoration(@NonNull DividerItemDecoration decoration) {
+ recyclerView.removeItemDecoration(dividerDecoration);
+ dividerDecoration = decoration;
+ recyclerView.addItemDecoration(dividerDecoration);
+ updateDivider();
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/template/RecyclerViewScrollHandlingDelegate.java b/main/src/com/google/android/setupdesign/template/RecyclerViewScrollHandlingDelegate.java
new file mode 100644
index 0000000..71094cf
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/template/RecyclerViewScrollHandlingDelegate.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright (C) 2017 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.google.android.setupdesign.template;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.recyclerview.widget.RecyclerView;
+import android.util.Log;
+import com.google.android.setupdesign.template.RequireScrollMixin.ScrollHandlingDelegate;
+
+/**
+ * {@link ScrollHandlingDelegate} which analyzes scroll events from {@link RecyclerView} and
+ * notifies {@link RequireScrollMixin} about scrollability changes.
+ */
+public class RecyclerViewScrollHandlingDelegate implements ScrollHandlingDelegate {
+
+ private static final String TAG = "RVRequireScrollMixin";
+
+ @Nullable private final RecyclerView recyclerView;
+
+ @NonNull private final RequireScrollMixin requireScrollMixin;
+
+ public RecyclerViewScrollHandlingDelegate(
+ @NonNull RequireScrollMixin requireScrollMixin, @Nullable RecyclerView recyclerView) {
+ this.requireScrollMixin = requireScrollMixin;
+ this.recyclerView = recyclerView;
+ }
+
+ private boolean canScrollDown() {
+ if (recyclerView != null) {
+ // Compatibility implementation of View#canScrollVertically
+ final int offset = recyclerView.computeVerticalScrollOffset();
+ final int range =
+ recyclerView.computeVerticalScrollRange() - recyclerView.computeVerticalScrollExtent();
+ return range != 0 && offset < range - 1;
+ }
+ return false;
+ }
+
+ @Override
+ public void startListening() {
+ if (this.recyclerView != null) {
+ this.recyclerView.addOnScrollListener(
+ new RecyclerView.OnScrollListener() {
+ @Override
+ public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
+ requireScrollMixin.notifyScrollabilityChange(canScrollDown());
+ }
+ });
+
+ if (canScrollDown()) {
+ requireScrollMixin.notifyScrollabilityChange(true);
+ }
+ } else {
+ Log.w(TAG, "Cannot require scroll. Recycler view is null.");
+ }
+ }
+
+ @Override
+ public void pageScrollDown() {
+ if (recyclerView != null) {
+ final int height = recyclerView.getHeight();
+ recyclerView.smoothScrollBy(0, height);
+ }
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/template/RequireScrollMixin.java b/main/src/com/google/android/setupdesign/template/RequireScrollMixin.java
new file mode 100644
index 0000000..338d60a
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/template/RequireScrollMixin.java
@@ -0,0 +1,240 @@
+/*
+ * Copyright (C) 2017 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.google.android.setupdesign.template;
+
+import android.os.Handler;
+import android.os.Looper;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.StringRes;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.widget.Button;
+import com.google.android.setupcompat.TemplateLayout;
+import com.google.android.setupcompat.template.Mixin;
+import com.google.android.setupdesign.view.NavigationBar;
+
+/**
+ * A mixin to require the a scrollable container (BottomScrollView, RecyclerView or ListView) to be
+ * scrolled to bottom, making sure that the user sees all content above and below the fold.
+ */
+public class RequireScrollMixin implements Mixin {
+
+ /* static section */
+
+ /**
+ * Listener for when the require-scroll state changes. Note that this only requires the user to
+ * scroll to the bottom once - if the user scrolled to the bottom and back-up, scrolling to bottom
+ * is not required again.
+ */
+ public interface OnRequireScrollStateChangedListener {
+
+ /**
+ * Called when require-scroll state changed.
+ *
+ * @param scrollNeeded True if the user should be required to scroll to bottom.
+ */
+ void onRequireScrollStateChanged(boolean scrollNeeded);
+ }
+
+ /**
+ * A delegate to detect scrollability changes and to scroll the page. This provides a layer of
+ * abstraction for BottomScrollView, RecyclerView and ListView. The delegate should call {@link
+ * #notifyScrollabilityChange(boolean)} when the view scrollability is changed.
+ */
+ interface ScrollHandlingDelegate {
+
+ /** Starts listening to scrollability changes at the target scrollable container. */
+ void startListening();
+
+ /** Scroll the page content down by one page. */
+ void pageScrollDown();
+ }
+
+ /* non-static section */
+
+ private final Handler handler = new Handler(Looper.getMainLooper());
+
+ private boolean requiringScrollToBottom = false;
+
+ // Whether the user have seen the more button yet.
+ private boolean everScrolledToBottom = false;
+
+ private ScrollHandlingDelegate delegate;
+
+ @Nullable private OnRequireScrollStateChangedListener listener;
+
+ /** @param templateLayout The template containing this mixin */
+ public RequireScrollMixin(@NonNull TemplateLayout templateLayout) {
+ }
+
+ /**
+ * Sets the delegate to handle scrolling. The type of delegate should depend on whether the
+ * scrolling view is a BottomScrollView, RecyclerView or ListView.
+ */
+ public void setScrollHandlingDelegate(@NonNull ScrollHandlingDelegate delegate) {
+ this.delegate = delegate;
+ }
+
+ /**
+ * Listen to require scroll state changes. When scroll is required, {@link
+ * OnRequireScrollStateChangedListener#onRequireScrollStateChanged(boolean)} is called with {@code
+ * true}, and vice versa.
+ */
+ public void setOnRequireScrollStateChangedListener(
+ @Nullable OnRequireScrollStateChangedListener listener) {
+ this.listener = listener;
+ }
+
+ /** @return The scroll state listener previously set, or {@code null} if none is registered. */
+ public OnRequireScrollStateChangedListener getOnRequireScrollStateChangedListener() {
+ return listener;
+ }
+
+ /**
+ * Creates an {@link OnClickListener} which if scrolling is required, will scroll the page down,
+ * and if scrolling is not required, delegates to the wrapped {@code listener}. Note that you
+ * should call {@link #requireScroll()} as well in order to start requiring scrolling.
+ *
+ * @param listener The listener to be invoked when scrolling is not needed and the user taps on
+ * the button. If {@code null}, the click listener will be a no-op when scroll is not
+ * required.
+ * @return A new {@link OnClickListener} which will scroll the page down or delegate to the given
+ * listener depending on the current require-scroll state.
+ */
+ public OnClickListener createOnClickListener(@Nullable final OnClickListener listener) {
+ return new OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ if (requiringScrollToBottom) {
+ delegate.pageScrollDown();
+ } else if (listener != null) {
+ listener.onClick(view);
+ }
+ }
+ };
+ }
+
+ /**
+ * Coordinate with the given navigation bar to require scrolling on the page. The more button will
+ * be shown instead of the next button while scrolling is required.
+ */
+ public void requireScrollWithNavigationBar(@NonNull final NavigationBar navigationBar) {
+ setOnRequireScrollStateChangedListener(
+ new OnRequireScrollStateChangedListener() {
+ @Override
+ public void onRequireScrollStateChanged(boolean scrollNeeded) {
+ navigationBar.getMoreButton().setVisibility(scrollNeeded ? View.VISIBLE : View.GONE);
+ navigationBar.getNextButton().setVisibility(scrollNeeded ? View.GONE : View.VISIBLE);
+ }
+ });
+ navigationBar.getMoreButton().setOnClickListener(createOnClickListener(null));
+ requireScroll();
+ }
+
+ /** @see #requireScrollWithButton(Button, CharSequence, OnClickListener) */
+ public void requireScrollWithButton(
+ @NonNull Button button, @StringRes int moreText, @Nullable OnClickListener onClickListener) {
+ requireScrollWithButton(button, button.getContext().getText(moreText), onClickListener);
+ }
+
+ /**
+ * Use the given {@code button} to require scrolling. When scrolling is required, the button label
+ * will change to {@code moreText}, and tapping the button will cause the page to scroll down.
+ *
+ * <p>Note: Calling {@link View#setOnClickListener} on the button after this method will remove
+ * its link to the require-scroll mechanism. If you need to do that, obtain the click listener
+ * from {@link #createOnClickListener(OnClickListener)}.
+ *
+ * <p>Note: The normal button label is taken from the button's text at the time of calling this
+ * method. Calling {@link android.widget.TextView#setText} after calling this method causes
+ * undefined behavior.
+ *
+ * @param button The button to use for require scroll. The button's "normal" label is taken from
+ * the text at the time of calling this method, and the click listener of it will be replaced.
+ * @param moreText The button label when scroll is required.
+ * @param onClickListener The listener for clicks when scrolling is not required.
+ */
+ public void requireScrollWithButton(
+ @NonNull final Button button,
+ final CharSequence moreText,
+ @Nullable OnClickListener onClickListener) {
+ final CharSequence nextText = button.getText();
+ button.setOnClickListener(createOnClickListener(onClickListener));
+ setOnRequireScrollStateChangedListener(
+ new OnRequireScrollStateChangedListener() {
+ @Override
+ public void onRequireScrollStateChanged(boolean scrollNeeded) {
+ button.setText(scrollNeeded ? moreText : nextText);
+ }
+ });
+ requireScroll();
+ }
+
+ /**
+ * @return True if scrolling is required. Note that this mixin only requires the user to scroll to
+ * the bottom once - if the user scrolled to the bottom and back-up, scrolling to bottom is
+ * not required again.
+ */
+ public boolean isScrollingRequired() {
+ return requiringScrollToBottom;
+ }
+
+ /**
+ * Start requiring scrolling on the layout. After calling this method, this mixin will start
+ * listening to scroll events from the scrolling container, and call {@link
+ * OnRequireScrollStateChangedListener} when the scroll state changes.
+ */
+ public void requireScroll() {
+ delegate.startListening();
+ }
+
+ /**
+ * {@link ScrollHandlingDelegate} should call this method when the scrollability of the scrolling
+ * container changed, so this mixin can recompute whether scrolling should be required.
+ *
+ * @param canScrollDown True if the view can scroll down further.
+ */
+ void notifyScrollabilityChange(boolean canScrollDown) {
+ if (canScrollDown == requiringScrollToBottom) {
+ // Already at the desired require-scroll state
+ return;
+ }
+ if (canScrollDown) {
+ if (!everScrolledToBottom) {
+ postScrollStateChange(true);
+ requiringScrollToBottom = true;
+ }
+ } else {
+ postScrollStateChange(false);
+ requiringScrollToBottom = false;
+ everScrolledToBottom = true;
+ }
+ }
+
+ private void postScrollStateChange(final boolean scrollNeeded) {
+ handler.post(
+ new Runnable() {
+ @Override
+ public void run() {
+ if (listener != null) {
+ listener.onRequireScrollStateChanged(scrollNeeded);
+ }
+ }
+ });
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/template/ScrollViewScrollHandlingDelegate.java b/main/src/com/google/android/setupdesign/template/ScrollViewScrollHandlingDelegate.java
new file mode 100644
index 0000000..0fbe5ce
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/template/ScrollViewScrollHandlingDelegate.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright (C) 2017 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.google.android.setupdesign.template;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import android.util.Log;
+import android.widget.ScrollView;
+import com.google.android.setupdesign.template.RequireScrollMixin.ScrollHandlingDelegate;
+import com.google.android.setupdesign.view.BottomScrollView;
+import com.google.android.setupdesign.view.BottomScrollView.BottomScrollListener;
+
+/**
+ * {@link ScrollHandlingDelegate} which analyzes scroll events from {@link BottomScrollView} and
+ * notifies {@link RequireScrollMixin} about scrollability changes.
+ */
+public class ScrollViewScrollHandlingDelegate
+ implements ScrollHandlingDelegate, BottomScrollListener {
+
+ private static final String TAG = "ScrollViewDelegate";
+
+ @NonNull private final RequireScrollMixin requireScrollMixin;
+
+ @Nullable private final BottomScrollView scrollView;
+
+ public ScrollViewScrollHandlingDelegate(
+ @NonNull RequireScrollMixin requireScrollMixin, @Nullable ScrollView scrollView) {
+ this.requireScrollMixin = requireScrollMixin;
+ if (scrollView instanceof BottomScrollView) {
+ this.scrollView = (BottomScrollView) scrollView;
+ } else {
+ Log.w(TAG, "Cannot set non-BottomScrollView. Found=" + scrollView);
+ this.scrollView = null;
+ }
+ }
+
+ @Override
+ public void onScrolledToBottom() {
+ requireScrollMixin.notifyScrollabilityChange(false);
+ }
+
+ @Override
+ public void onRequiresScroll() {
+ requireScrollMixin.notifyScrollabilityChange(true);
+ }
+
+ @Override
+ public void startListening() {
+ if (scrollView != null) {
+ scrollView.setBottomScrollListener(this);
+ } else {
+ Log.w(TAG, "Cannot require scroll. Scroll view is null.");
+ }
+ }
+
+ @Override
+ public void pageScrollDown() {
+ if (scrollView != null) {
+ scrollView.pageScroll(ScrollView.FOCUS_DOWN);
+ }
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/util/DrawableLayoutDirectionHelper.java b/main/src/com/google/android/setupdesign/util/DrawableLayoutDirectionHelper.java
new file mode 100644
index 0000000..e549c55
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/util/DrawableLayoutDirectionHelper.java
@@ -0,0 +1,99 @@
+/*
+ * Copyright (C) 2015 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.google.android.setupdesign.util;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.InsetDrawable;
+import android.os.Build;
+import android.view.View;
+
+/** Provides convenience methods to handle drawable layout directions in different SDK versions. */
+public class DrawableLayoutDirectionHelper {
+
+ /**
+ * Creates an {@link android.graphics.drawable.InsetDrawable} according to the layout direction of
+ * {@code view}.
+ */
+ @SuppressLint("InlinedApi") // Use of View.LAYOUT_DIRECTION_RTL is guarded by version check
+ public static InsetDrawable createRelativeInsetDrawable(
+ Drawable drawable, int insetStart, int insetTop, int insetEnd, int insetBottom, View view) {
+ boolean isRtl =
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1
+ && view.getLayoutDirection() == View.LAYOUT_DIRECTION_RTL;
+ return createRelativeInsetDrawable(
+ drawable, insetStart, insetTop, insetEnd, insetBottom, isRtl);
+ }
+
+ /**
+ * Creates an {@link android.graphics.drawable.InsetDrawable} according to the layout direction of
+ * {@code context}.
+ */
+ @SuppressLint("InlinedApi") // Use of View.LAYOUT_DIRECTION_RTL is guarded by version check
+ public static InsetDrawable createRelativeInsetDrawable(
+ Drawable drawable,
+ int insetStart,
+ int insetTop,
+ int insetEnd,
+ int insetBottom,
+ Context context) {
+ boolean isRtl = false;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
+ final int layoutDirection = context.getResources().getConfiguration().getLayoutDirection();
+ isRtl = layoutDirection == View.LAYOUT_DIRECTION_RTL;
+ }
+ return createRelativeInsetDrawable(
+ drawable, insetStart, insetTop, insetEnd, insetBottom, isRtl);
+ }
+
+ /**
+ * Creates an {@link android.graphics.drawable.InsetDrawable} according to {@code
+ * layoutDirection}.
+ */
+ @SuppressLint("InlinedApi") // Given layoutDirection will not be View.LAYOUT_DIRECTION_RTL if
+ // SDK version doesn't support it.
+ public static InsetDrawable createRelativeInsetDrawable(
+ Drawable drawable,
+ int insetStart,
+ int insetTop,
+ int insetEnd,
+ int insetBottom,
+ int layoutDirection) {
+ return createRelativeInsetDrawable(
+ drawable,
+ insetStart,
+ insetTop,
+ insetEnd,
+ insetBottom,
+ layoutDirection == View.LAYOUT_DIRECTION_RTL);
+ }
+
+ private static InsetDrawable createRelativeInsetDrawable(
+ Drawable drawable,
+ int insetStart,
+ int insetTop,
+ int insetEnd,
+ int insetBottom,
+ boolean isRtl) {
+ if (isRtl) {
+ return new InsetDrawable(drawable, insetEnd, insetTop, insetStart, insetBottom);
+ } else {
+ return new InsetDrawable(drawable, insetStart, insetTop, insetEnd, insetBottom);
+ }
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/util/LinkAccessibilityHelper.java b/main/src/com/google/android/setupdesign/util/LinkAccessibilityHelper.java
new file mode 100644
index 0000000..fd0d17f
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/util/LinkAccessibilityHelper.java
@@ -0,0 +1,326 @@
+/*
+ * Copyright (C) 2016 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.google.android.setupdesign.util;
+
+import android.graphics.Rect;
+import android.os.Build;
+import android.os.Bundle;
+import androidx.annotation.NonNull;
+import androidx.annotation.VisibleForTesting;
+import androidx.core.view.AccessibilityDelegateCompat;
+import androidx.core.view.accessibility.AccessibilityNodeInfoCompat;
+import androidx.core.view.accessibility.AccessibilityNodeProviderCompat;
+import androidx.customview.widget.ExploreByTouchHelper;
+import android.text.Layout;
+import android.text.Spanned;
+import android.text.style.ClickableSpan;
+import android.util.Log;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.accessibility.AccessibilityEvent;
+import android.widget.TextView;
+import java.util.List;
+
+/**
+ * An accessibility delegate that allows {@link android.text.style.ClickableSpan} to be focused and
+ * clicked by accessibility services.
+ *
+ * <p><strong>Note:</strong> This class is a no-op on Android O or above since there is native
+ * support for ClickableSpan accessibility.
+ *
+ * <p>Sample usage:
+ *
+ * <pre>
+ * LinkAccessibilityHelper mAccessibilityHelper;
+ *
+ * private void init() {
+ * mAccessibilityHelper = new LinkAccessibilityHelper(myTextView);
+ * ViewCompat.setAccessibilityDelegate(myTextView, mLinkHelper);
+ * }
+ *
+ * {@literal @}Override
+ * protected boolean dispatchHoverEvent({@literal @}NonNull MotionEvent event) {
+ * if (mAccessibilityHelper != null && mAccessibilityHelper.dispatchHoverEvent(event)) {
+ * return true;
+ * }
+ * return super.dispatchHoverEvent(event);
+ * }
+ * </pre>
+ *
+ * @see com.google.android.setupdesign.view.RichTextView
+ * @see androidx.customview.widget.ExploreByTouchHelper
+ */
+public class LinkAccessibilityHelper extends AccessibilityDelegateCompat {
+
+ private static final String TAG = "LinkAccessibilityHelper";
+
+ private final AccessibilityDelegateCompat delegate;
+
+ public LinkAccessibilityHelper(TextView view) {
+ this(
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
+ // Platform support was added in O. This helper will be no-op
+ ? new AccessibilityDelegateCompat()
+ // Pre-O, we extend ExploreByTouchHelper to expose a virtual view hierarchy
+ : new PreOLinkAccessibilityHelper(view));
+ }
+
+ @VisibleForTesting
+ LinkAccessibilityHelper(@NonNull AccessibilityDelegateCompat delegate) {
+ this.delegate = delegate;
+ }
+
+ @Override
+ public void sendAccessibilityEvent(View host, int eventType) {
+ delegate.sendAccessibilityEvent(host, eventType);
+ }
+
+ @Override
+ public void sendAccessibilityEventUnchecked(View host, AccessibilityEvent event) {
+ delegate.sendAccessibilityEventUnchecked(host, event);
+ }
+
+ @Override
+ public boolean dispatchPopulateAccessibilityEvent(View host, AccessibilityEvent event) {
+ return delegate.dispatchPopulateAccessibilityEvent(host, event);
+ }
+
+ @Override
+ public void onPopulateAccessibilityEvent(View host, AccessibilityEvent event) {
+ delegate.onPopulateAccessibilityEvent(host, event);
+ }
+
+ @Override
+ public void onInitializeAccessibilityEvent(View host, AccessibilityEvent event) {
+ delegate.onInitializeAccessibilityEvent(host, event);
+ }
+
+ @Override
+ public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfoCompat info) {
+ delegate.onInitializeAccessibilityNodeInfo(host, info);
+ }
+
+ @Override
+ public boolean onRequestSendAccessibilityEvent(
+ ViewGroup host, View child, AccessibilityEvent event) {
+ return delegate.onRequestSendAccessibilityEvent(host, child, event);
+ }
+
+ @Override
+ public AccessibilityNodeProviderCompat getAccessibilityNodeProvider(View host) {
+ return delegate.getAccessibilityNodeProvider(host);
+ }
+
+ @Override
+ public boolean performAccessibilityAction(View host, int action, Bundle args) {
+ return delegate.performAccessibilityAction(host, action, args);
+ }
+
+ /**
+ * Dispatches hover event to the virtual view hierarchy. This method should be called in {@link
+ * View#dispatchHoverEvent(MotionEvent)}.
+ *
+ * @see ExploreByTouchHelper#dispatchHoverEvent(MotionEvent)
+ */
+ public boolean dispatchHoverEvent(MotionEvent event) {
+ return delegate instanceof ExploreByTouchHelper
+ && ((ExploreByTouchHelper) delegate).dispatchHoverEvent(event);
+ }
+
+ @VisibleForTesting
+ static class PreOLinkAccessibilityHelper extends ExploreByTouchHelper {
+
+ private final Rect tempRect = new Rect();
+ private final TextView view;
+
+ PreOLinkAccessibilityHelper(TextView view) {
+ super(view);
+ this.view = view;
+ }
+
+ @Override
+ protected int getVirtualViewAt(float x, float y) {
+ final CharSequence text = view.getText();
+ if (text instanceof Spanned) {
+ final Spanned spannedText = (Spanned) text;
+ final int offset = getOffsetForPosition(view, x, y);
+ ClickableSpan[] linkSpans = spannedText.getSpans(offset, offset, ClickableSpan.class);
+ if (linkSpans.length == 1) {
+ ClickableSpan linkSpan = linkSpans[0];
+ return spannedText.getSpanStart(linkSpan);
+ }
+ }
+ return ExploreByTouchHelper.INVALID_ID;
+ }
+
+ @Override
+ protected void getVisibleVirtualViews(List<Integer> virtualViewIds) {
+ final CharSequence text = view.getText();
+ if (text instanceof Spanned) {
+ final Spanned spannedText = (Spanned) text;
+ ClickableSpan[] linkSpans =
+ spannedText.getSpans(0, spannedText.length(), ClickableSpan.class);
+ for (ClickableSpan span : linkSpans) {
+ virtualViewIds.add(spannedText.getSpanStart(span));
+ }
+ }
+ }
+
+ @Override
+ protected void onPopulateEventForVirtualView(int virtualViewId, AccessibilityEvent event) {
+ final ClickableSpan span = getSpanForOffset(virtualViewId);
+ if (span != null) {
+ event.setContentDescription(getTextForSpan(span));
+ } else {
+ Log.e(TAG, "LinkSpan is null for offset: " + virtualViewId);
+ event.setContentDescription(view.getText());
+ }
+ }
+
+ @Override
+ protected void onPopulateNodeForVirtualView(
+ int virtualViewId, AccessibilityNodeInfoCompat info) {
+ final ClickableSpan span = getSpanForOffset(virtualViewId);
+ if (span != null) {
+ info.setContentDescription(getTextForSpan(span));
+ } else {
+ Log.e(TAG, "LinkSpan is null for offset: " + virtualViewId);
+ info.setContentDescription(view.getText());
+ }
+ info.setFocusable(true);
+ info.setClickable(true);
+ getBoundsForSpan(span, tempRect);
+ if (tempRect.isEmpty()) {
+ Log.e(TAG, "LinkSpan bounds is empty for: " + virtualViewId);
+ tempRect.set(0, 0, 1, 1);
+ }
+ info.setBoundsInParent(tempRect);
+ info.addAction(AccessibilityNodeInfoCompat.ACTION_CLICK);
+ }
+
+ @Override
+ protected boolean onPerformActionForVirtualView(
+ int virtualViewId, int action, Bundle arguments) {
+ if (action == AccessibilityNodeInfoCompat.ACTION_CLICK) {
+ ClickableSpan span = getSpanForOffset(virtualViewId);
+ if (span != null) {
+ span.onClick(view);
+ return true;
+ } else {
+ Log.e(TAG, "LinkSpan is null for offset: " + virtualViewId);
+ }
+ }
+ return false;
+ }
+
+ private ClickableSpan getSpanForOffset(int offset) {
+ CharSequence text = view.getText();
+ if (text instanceof Spanned) {
+ Spanned spannedText = (Spanned) text;
+ ClickableSpan[] spans = spannedText.getSpans(offset, offset, ClickableSpan.class);
+ if (spans.length == 1) {
+ return spans[0];
+ }
+ }
+ return null;
+ }
+
+ private CharSequence getTextForSpan(ClickableSpan span) {
+ CharSequence text = view.getText();
+ if (text instanceof Spanned) {
+ Spanned spannedText = (Spanned) text;
+ return spannedText.subSequence(
+ spannedText.getSpanStart(span), spannedText.getSpanEnd(span));
+ }
+ return text;
+ }
+
+ // Find the bounds of a span. If it spans multiple lines, it will only return the bounds for
+ // the section on the first line.
+ private Rect getBoundsForSpan(ClickableSpan span, Rect outRect) {
+ CharSequence text = view.getText();
+ outRect.setEmpty();
+ if (text instanceof Spanned) {
+ final Layout layout = view.getLayout();
+ if (layout != null) {
+ Spanned spannedText = (Spanned) text;
+ final int spanStart = spannedText.getSpanStart(span);
+ final int spanEnd = spannedText.getSpanEnd(span);
+ final float xStart = layout.getPrimaryHorizontal(spanStart);
+ final float xEnd = layout.getPrimaryHorizontal(spanEnd);
+ final int lineStart = layout.getLineForOffset(spanStart);
+ final int lineEnd = layout.getLineForOffset(spanEnd);
+ layout.getLineBounds(lineStart, outRect);
+ if (lineEnd == lineStart) {
+ // If the span is on a single line, adjust both the left and right bounds
+ // so outrect is exactly bounding the span.
+ outRect.left = (int) Math.min(xStart, xEnd);
+ outRect.right = (int) Math.max(xStart, xEnd);
+ } else {
+ // If the span wraps across multiple lines, only use the first line (as
+ // returned by layout.getLineBounds above), and adjust the "start" of
+ // outrect to where the span starts, leaving the "end" of outrect at the end
+ // of the line. ("start" being left for LTR, and right for RTL)
+ if (layout.getParagraphDirection(lineStart) == Layout.DIR_RIGHT_TO_LEFT) {
+ outRect.right = (int) xStart;
+ } else {
+ outRect.left = (int) xStart;
+ }
+ }
+
+ // Offset for padding
+ outRect.offset(view.getTotalPaddingLeft(), view.getTotalPaddingTop());
+ }
+ }
+ return outRect;
+ }
+
+ // Compat implementation of TextView#getOffsetForPosition().
+
+ private static int getOffsetForPosition(TextView view, float x, float y) {
+ if (view.getLayout() == null) {
+ return -1;
+ }
+ final int line = getLineAtCoordinate(view, y);
+ return getOffsetAtCoordinate(view, line, x);
+ }
+
+ private static float convertToLocalHorizontalCoordinate(TextView view, float x) {
+ x -= view.getTotalPaddingLeft();
+ // Clamp the position to inside of the view.
+ x = Math.max(0.0f, x);
+ x = Math.min(view.getWidth() - view.getTotalPaddingRight() - 1, x);
+ x += view.getScrollX();
+ return x;
+ }
+
+ private static int getLineAtCoordinate(TextView view, float y) {
+ y -= view.getTotalPaddingTop();
+ // Clamp the position to inside of the view.
+ y = Math.max(0.0f, y);
+ y = Math.min(view.getHeight() - view.getTotalPaddingBottom() - 1, y);
+ y += view.getScrollY();
+ return view.getLayout().getLineForVertical((int) y);
+ }
+
+ private static int getOffsetAtCoordinate(TextView view, int line, float x) {
+ x = convertToLocalHorizontalCoordinate(view, x);
+ return view.getLayout().getOffsetForHorizontal(line, x);
+ }
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/util/Partner.java b/main/src/com/google/android/setupdesign/util/Partner.java
new file mode 100644
index 0000000..d335490
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/util/Partner.java
@@ -0,0 +1,202 @@
+/*
+ * Copyright (C) 2015 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.google.android.setupdesign.util;
+
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.PackageManager;
+import android.content.pm.PackageManager.NameNotFoundException;
+import android.content.pm.ResolveInfo;
+import android.content.res.Resources;
+import android.graphics.drawable.Drawable;
+import android.os.Build.VERSION;
+import android.os.Build.VERSION_CODES;
+import androidx.annotation.AnyRes;
+import androidx.annotation.ColorRes;
+import androidx.annotation.DrawableRes;
+import androidx.annotation.Nullable;
+import androidx.annotation.StringRes;
+import androidx.annotation.VisibleForTesting;
+import android.util.Log;
+import java.util.List;
+
+/**
+ * Utilities to discover and interact with partner customizations. An overlay package is one that
+ * registers the broadcast receiver for {@code com.android.setupwizard.action.PARTNER_CUSTOMIZATION}
+ * in its manifest. There can only be one customization APK on a device, and it must be bundled with
+ * the system.
+ *
+ * <p>Derived from {@code com.android.launcher3/Partner.java}
+ */
+public class Partner {
+
+ private static final String TAG = "(SUW) Partner";
+
+ /** Marker action used to discover partner. */
+ private static final String ACTION_PARTNER_CUSTOMIZATION =
+ "com.android.setupwizard.action.PARTNER_CUSTOMIZATION";
+
+ private static boolean searched = false;
+ @Nullable private static Partner partner;
+
+ /**
+ * Gets a drawable from partner overlay, or if not available, the drawable from the original
+ * context.
+ *
+ * @see #getResourceEntry(android.content.Context, int)
+ */
+ public static Drawable getDrawable(Context context, @DrawableRes int id) {
+ final ResourceEntry entry = getResourceEntry(context, id);
+ return entry.resources.getDrawable(entry.id);
+ }
+
+ /**
+ * Gets a string from partner overlay, or if not available, the string from the original context.
+ *
+ * @see #getResourceEntry(android.content.Context, int)
+ */
+ public static String getString(Context context, @StringRes int id) {
+ final ResourceEntry entry = getResourceEntry(context, id);
+ return entry.resources.getString(entry.id);
+ }
+
+ /**
+ * Gets a color from partner overlay, or if not available, the color from the original context.
+ */
+ public static int getColor(Context context, @ColorRes int id) {
+ final ResourceEntry resourceEntry = getResourceEntry(context, id);
+ return resourceEntry.resources.getColor(resourceEntry.id);
+ }
+
+ /**
+ * Gets a CharSequence from partner overlay, or if not available, the text from the original
+ * context.
+ */
+ public static CharSequence getText(Context context, @StringRes int id) {
+ final ResourceEntry entry = getResourceEntry(context, id);
+ return entry.resources.getText(entry.id);
+ }
+
+ /**
+ * Finds an entry of resource in the overlay package provided by partners. It will first look for
+ * the resource in the overlay package, and if not available, will return the one in the original
+ * context.
+ *
+ * @return a ResourceEntry in the partner overlay's resources, if one is defined. Otherwise the
+ * resources from the original context is returned. Clients can then get the resource by
+ * {@code entry.resources.getString(entry.id)}, or other methods available in {@link
+ * android.content.res.Resources}.
+ */
+ public static ResourceEntry getResourceEntry(Context context, @AnyRes int id) {
+ final Partner partner = Partner.get(context);
+ if (partner != null) {
+ final Resources ourResources = context.getResources();
+ final String name = ourResources.getResourceEntryName(id);
+ final String type = ourResources.getResourceTypeName(id);
+ final int partnerId = partner.getIdentifier(name, type);
+ if (partnerId != 0) {
+ return new ResourceEntry(partner.getPackageName(), partner.resources, partnerId, true);
+ }
+ }
+ return new ResourceEntry(context.getPackageName(), context.getResources(), id, false);
+ }
+
+ public static class ResourceEntry {
+ public String packageName;
+ public Resources resources;
+ public int id;
+ public boolean isOverlay;
+
+ ResourceEntry(String packageName, Resources resources, int id, boolean isOverlay) {
+ this.packageName = packageName;
+ this.resources = resources;
+ this.id = id;
+ this.isOverlay = isOverlay;
+ }
+ }
+
+ /**
+ * Finds and returns partner details, or {@code null} if none exists. A partner package is marked
+ * by a broadcast receiver declared in the manifest that handles the {@code
+ * com.android.setupwizard.action.PARTNER_CUSTOMIZATION} intent action. The overlay package must
+ * also be a system package.
+ */
+ public static synchronized Partner get(Context context) {
+ if (!searched) {
+ PackageManager pm = context.getPackageManager();
+ final Intent intent = new Intent(ACTION_PARTNER_CUSTOMIZATION);
+ List<ResolveInfo> receivers;
+ if (VERSION.SDK_INT >= VERSION_CODES.N) {
+ receivers =
+ pm.queryBroadcastReceivers(
+ intent,
+ PackageManager.MATCH_SYSTEM_ONLY
+ | PackageManager.MATCH_DIRECT_BOOT_AWARE
+ | PackageManager.MATCH_DIRECT_BOOT_UNAWARE);
+ } else {
+ // On versions before N, direct boot doesn't exist. And the MATCH_SYSTEM_ONLY flag
+ // doesn't exist so we filter for system apps in code below.
+ receivers = pm.queryBroadcastReceivers(intent, 0);
+ }
+
+ for (ResolveInfo info : receivers) {
+ if (info.activityInfo == null) {
+ continue;
+ }
+ final ApplicationInfo appInfo = info.activityInfo.applicationInfo;
+ if ((appInfo.flags & ApplicationInfo.FLAG_SYSTEM) != 0) {
+ try {
+ final Resources res = pm.getResourcesForApplication(appInfo);
+ partner = new Partner(appInfo.packageName, res);
+ break;
+ } catch (NameNotFoundException e) {
+ Log.w(TAG, "Failed to find resources for " + appInfo.packageName);
+ }
+ }
+ }
+ searched = true;
+ }
+ return partner;
+ }
+
+ @VisibleForTesting
+ public static synchronized void resetForTesting() {
+ searched = false;
+ partner = null;
+ }
+
+ private final String packageName;
+ private final Resources resources;
+
+ private Partner(String packageName, Resources res) {
+ this.packageName = packageName;
+ resources = res;
+ }
+
+ public String getPackageName() {
+ return packageName;
+ }
+
+ public Resources getResources() {
+ return resources;
+ }
+
+ public int getIdentifier(String name, String defType) {
+ return resources.getIdentifier(name, defType, packageName);
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/util/SystemBarHelper.java b/main/src/com/google/android/setupdesign/util/SystemBarHelper.java
new file mode 100644
index 0000000..e784fb0
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/util/SystemBarHelper.java
@@ -0,0 +1,358 @@
+/*
+ * Copyright (C) 2015 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.google.android.setupdesign.util;
+
+import android.annotation.SuppressLint;
+import android.annotation.TargetApi;
+import android.app.Dialog;
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.os.Build.VERSION;
+import android.os.Build.VERSION_CODES;
+import android.os.Handler;
+import androidx.annotation.RequiresPermission;
+import android.util.Log;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.Window;
+import android.view.WindowInsets;
+import android.view.WindowManager;
+
+/**
+ * A helper class to manage the system navigation bar and status bar. This will add various
+ * systemUiVisibility flags to the given Window or View to make them follow the Setup Wizard style.
+ *
+ * <p>When the useImmersiveMode intent extra is true, a screen in Setup Wizard should hide the
+ * system bars using methods from this class. For Lollipop, {@link
+ * #hideSystemBars(android.view.Window)} will completely hide the system navigation bar and change
+ * the status bar to transparent, and layout the screen contents (usually the illustration) behind
+ * it.
+ */
+public class SystemBarHelper {
+
+ private static final String TAG = "SystemBarHelper";
+
+ @SuppressLint("InlinedApi")
+ private static final int DEFAULT_IMMERSIVE_FLAGS =
+ View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
+ | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
+ | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
+ | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION;
+
+ @SuppressLint("InlinedApi")
+ private static final int DIALOG_IMMERSIVE_FLAGS =
+ View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY;
+
+ /** Needs to be equal to View.STATUS_BAR_DISABLE_BACK */
+ private static final int STATUS_BAR_DISABLE_BACK = 0x00400000;
+
+ /**
+ * The maximum number of retries when peeking the decor view. When polling for the decor view,
+ * waiting it to be installed, set a maximum number of retries.
+ */
+ private static final int PEEK_DECOR_VIEW_RETRIES = 3;
+
+ /**
+ * Hide the navigation bar for a dialog.
+ *
+ * <p>This will only take effect in versions Lollipop or above. Otherwise this is a no-op.
+ */
+ public static void hideSystemBars(final Dialog dialog) {
+ if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) {
+ final Window window = dialog.getWindow();
+ temporarilyDisableDialogFocus(window);
+ addVisibilityFlag(window, DIALOG_IMMERSIVE_FLAGS);
+ addImmersiveFlagsToDecorView(window, DIALOG_IMMERSIVE_FLAGS);
+
+ // Also set the navigation bar and status bar to transparent color. Note that this
+ // doesn't work if android.R.boolean.config_enableTranslucentDecor is false.
+ window.setNavigationBarColor(0);
+ window.setStatusBarColor(0);
+ }
+ }
+
+ /**
+ * Hide the navigation bar, make the color of the status and navigation bars transparent, and
+ * specify {@link View#SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN} flag so that the content is laid-out
+ * behind the transparent status bar. This is commonly used with {@link
+ * android.app.Activity#getWindow()} to make the navigation and status bars follow the Setup
+ * Wizard style.
+ *
+ * <p>This will only take effect in versions Lollipop or above. Otherwise this is a no-op.
+ */
+ public static void hideSystemBars(final Window window) {
+ if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) {
+ addVisibilityFlag(window, DEFAULT_IMMERSIVE_FLAGS);
+ addImmersiveFlagsToDecorView(window, DEFAULT_IMMERSIVE_FLAGS);
+
+ // Also set the navigation bar and status bar to transparent color. Note that this
+ // doesn't work if android.R.boolean.config_enableTranslucentDecor is false.
+ window.setNavigationBarColor(0);
+ window.setStatusBarColor(0);
+ }
+ }
+
+ /**
+ * Revert the actions of hideSystemBars. Note that this will remove the system UI visibility flags
+ * regardless of whether it is originally present. You should also manually reset the navigation
+ * bar and status bar colors, as this method doesn't know what value to revert it to.
+ */
+ public static void showSystemBars(final Dialog dialog, final Context context) {
+ showSystemBars(dialog.getWindow(), context);
+ }
+
+ /**
+ * Revert the actions of hideSystemBars. Note that this will remove the system UI visibility flags
+ * regardless of whether it is originally present. You should also manually reset the navigation
+ * bar and status bar colors, as this method doesn't know what value to revert it to.
+ */
+ public static void showSystemBars(final Window window, final Context context) {
+ if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) {
+ removeVisibilityFlag(window, DEFAULT_IMMERSIVE_FLAGS);
+ removeImmersiveFlagsFromDecorView(window, DEFAULT_IMMERSIVE_FLAGS);
+
+ if (context != null) {
+ //noinspection AndroidLintInlinedApi
+ final TypedArray typedArray =
+ context.obtainStyledAttributes(
+ new int[] {android.R.attr.statusBarColor, android.R.attr.navigationBarColor});
+ final int statusBarColor = typedArray.getColor(0, 0);
+ final int navigationBarColor = typedArray.getColor(1, 0);
+ window.setStatusBarColor(statusBarColor);
+ window.setNavigationBarColor(navigationBarColor);
+ typedArray.recycle();
+ }
+ }
+ }
+
+ /** Convenience method to add a visibility flag in addition to the existing ones. */
+ public static void addVisibilityFlag(final View view, final int flag) {
+ if (VERSION.SDK_INT >= VERSION_CODES.HONEYCOMB) {
+ final int vis = view.getSystemUiVisibility();
+ view.setSystemUiVisibility(vis | flag);
+ }
+ }
+
+ /** Convenience method to add a visibility flag in addition to the existing ones. */
+ public static void addVisibilityFlag(final Window window, final int flag) {
+ if (VERSION.SDK_INT >= VERSION_CODES.HONEYCOMB) {
+ WindowManager.LayoutParams attrs = window.getAttributes();
+ attrs.systemUiVisibility |= flag;
+ window.setAttributes(attrs);
+ }
+ }
+
+ /**
+ * Convenience method to remove a visibility flag from the view, leaving other flags that are not
+ * specified intact.
+ */
+ public static void removeVisibilityFlag(final View view, final int flag) {
+ if (VERSION.SDK_INT >= VERSION_CODES.HONEYCOMB) {
+ final int vis = view.getSystemUiVisibility();
+ view.setSystemUiVisibility(vis & ~flag);
+ }
+ }
+
+ /**
+ * Convenience method to remove a visibility flag from the window, leaving other flags that are
+ * not specified intact.
+ */
+ public static void removeVisibilityFlag(final Window window, final int flag) {
+ if (VERSION.SDK_INT >= VERSION_CODES.HONEYCOMB) {
+ WindowManager.LayoutParams attrs = window.getAttributes();
+ attrs.systemUiVisibility &= ~flag;
+ window.setAttributes(attrs);
+ }
+ }
+
+ /**
+ * Sets whether the back button on the software navigation bar is visible. This only works if you
+ * have the STATUS_BAR permission. Otherwise framework will filter out this flag and this method
+ * call will not have any effect.
+ *
+ * <p>IMPORTANT: Do not assume that users have no way to go back when the back button is hidden.
+ * Many devices have physical back buttons, and accessibility services like TalkBack may have
+ * gestures mapped to back. Please use onBackPressed, onKeyDown, or other similar ways to make
+ * sure back button events are still handled (or ignored) properly.
+ */
+ @RequiresPermission("android.permission.STATUS_BAR")
+ public static void setBackButtonVisible(final Window window, final boolean visible) {
+ if (VERSION.SDK_INT >= VERSION_CODES.HONEYCOMB) {
+ if (visible) {
+ removeVisibilityFlag(window, STATUS_BAR_DISABLE_BACK);
+ removeImmersiveFlagsFromDecorView(window, STATUS_BAR_DISABLE_BACK);
+ } else {
+ addVisibilityFlag(window, STATUS_BAR_DISABLE_BACK);
+ addImmersiveFlagsToDecorView(window, STATUS_BAR_DISABLE_BACK);
+ }
+ }
+ }
+
+ /**
+ * Set a view to be resized when the keyboard is shown. This will set the bottom margin of the
+ * view to be immediately above the keyboard, and assumes that the view sits immediately above the
+ * navigation bar.
+ *
+ * <p>Note that you must set {@link android.R.attr#windowSoftInputMode} to {@code adjustResize}
+ * for this class to work. Otherwise window insets are not dispatched and this method will have no
+ * effect.
+ *
+ * <p>This will only take effect in versions Lollipop or above. Otherwise this is a no-op.
+ *
+ * @param view The view to be resized when the keyboard is shown.
+ */
+ public static void setImeInsetView(final View view) {
+ if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) {
+ view.setOnApplyWindowInsetsListener(new WindowInsetsListener());
+ }
+ }
+
+ /**
+ * Add the specified immersive flags to the decor view of the window, because {@link
+ * View#SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN} only takes effect when it is added to a view instead of
+ * the window.
+ */
+ @TargetApi(VERSION_CODES.HONEYCOMB)
+ private static void addImmersiveFlagsToDecorView(final Window window, final int vis) {
+ getDecorView(
+ window,
+ new OnDecorViewInstalledListener() {
+ @Override
+ public void onDecorViewInstalled(View decorView) {
+ addVisibilityFlag(decorView, vis);
+ }
+ });
+ }
+
+ @TargetApi(VERSION_CODES.HONEYCOMB)
+ private static void removeImmersiveFlagsFromDecorView(final Window window, final int vis) {
+ getDecorView(
+ window,
+ new OnDecorViewInstalledListener() {
+ @Override
+ public void onDecorViewInstalled(View decorView) {
+ removeVisibilityFlag(decorView, vis);
+ }
+ });
+ }
+
+ private static void getDecorView(Window window, OnDecorViewInstalledListener callback) {
+ new DecorViewFinder().getDecorView(window, callback, PEEK_DECOR_VIEW_RETRIES);
+ }
+
+ private static class DecorViewFinder {
+
+ private final Handler handler = new Handler();
+ private Window window;
+ private int retries;
+ private OnDecorViewInstalledListener callback;
+
+ private final Runnable checkDecorViewRunnable =
+ new Runnable() {
+ @Override
+ public void run() {
+ // Use peekDecorView instead of getDecorView so that clients can still set window
+ // features after calling this method.
+ final View decorView = window.peekDecorView();
+ if (decorView != null) {
+ callback.onDecorViewInstalled(decorView);
+ } else {
+ retries--;
+ if (retries >= 0) {
+ // If the decor view is not installed yet, try again in the next loop.
+ handler.post(checkDecorViewRunnable);
+ } else {
+ Log.w(TAG, "Cannot get decor view of window: " + window);
+ }
+ }
+ }
+ };
+
+ public void getDecorView(Window window, OnDecorViewInstalledListener callback, int retries) {
+ this.window = window;
+ this.retries = retries;
+ this.callback = callback;
+ checkDecorViewRunnable.run();
+ }
+ }
+
+ private interface OnDecorViewInstalledListener {
+
+ void onDecorViewInstalled(View decorView);
+ }
+
+ /**
+ * Apply a hack to temporarily set the window to not focusable, so that the navigation bar will
+ * not show up during the transition.
+ */
+ private static void temporarilyDisableDialogFocus(final Window window) {
+ window.setFlags(
+ WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
+ WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE);
+ // Add the SOFT_INPUT_IS_FORWARD_NAVIGATION_FLAG. This is normally done by the system when
+ // FLAG_NOT_FOCUSABLE is not set. Setting this flag allows IME to be shown automatically
+ // if the dialog has editable text fields.
+ window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_IS_FORWARD_NAVIGATION);
+ new Handler()
+ .post(
+ new Runnable() {
+ @Override
+ public void run() {
+ window.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE);
+ }
+ });
+ }
+
+ @TargetApi(VERSION_CODES.LOLLIPOP)
+ private static class WindowInsetsListener implements View.OnApplyWindowInsetsListener {
+ private int bottomOffset;
+ private boolean hasCalculatedBottomOffset = false;
+
+ @Override
+ public WindowInsets onApplyWindowInsets(View view, WindowInsets insets) {
+ if (!hasCalculatedBottomOffset) {
+ bottomOffset = getBottomDistance(view);
+ hasCalculatedBottomOffset = true;
+ }
+
+ int bottomInset = insets.getSystemWindowInsetBottom();
+
+ final int bottomMargin = Math.max(insets.getSystemWindowInsetBottom() - bottomOffset, 0);
+
+ final ViewGroup.MarginLayoutParams lp = (ViewGroup.MarginLayoutParams) view.getLayoutParams();
+ // Check that we have enough space to apply the bottom margins before applying it.
+ // Otherwise the framework may think that the view is empty and exclude it from layout.
+ if (bottomMargin < lp.bottomMargin + view.getHeight()) {
+ lp.setMargins(lp.leftMargin, lp.topMargin, lp.rightMargin, bottomMargin);
+ view.setLayoutParams(lp);
+ bottomInset = 0;
+ }
+
+ return insets.replaceSystemWindowInsets(
+ insets.getSystemWindowInsetLeft(),
+ insets.getSystemWindowInsetTop(),
+ insets.getSystemWindowInsetRight(),
+ bottomInset);
+ }
+ }
+
+ private static int getBottomDistance(View view) {
+ int[] coords = new int[2];
+ view.getLocationInWindow(coords);
+ return view.getRootView().getHeight() - coords[1] - view.getHeight();
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/util/ThemeHelper.java b/main/src/com/google/android/setupdesign/util/ThemeHelper.java
new file mode 100644
index 0000000..4247d99
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/util/ThemeHelper.java
@@ -0,0 +1,128 @@
+/*
+ * Copyright (C) 2015 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.google.android.setupdesign.util;
+
+import android.app.Activity;
+import android.content.Intent;
+import com.google.android.setupcompat.util.WizardManagerHelper;
+
+/** The helper class holds the constant names of themes and util functions */
+public class ThemeHelper {
+
+ /**
+ * Passed in a setup wizard intent as {@link WizardManagerHelper#EXTRA_THEME}. This is the dark
+ * variant of the theme used in setup wizard for Nougat MR1.
+ */
+ public static final String THEME_GLIF = "glif";
+
+ /**
+ * Passed in a setup wizard intent as {@link WizardManagerHelper#EXTRA_THEME}. This is the default
+ * theme used in setup wizard for Nougat MR1.
+ */
+ public static final String THEME_GLIF_LIGHT = "glif_light";
+
+ /**
+ * Passed in a setup wizard intent as {@link WizardManagerHelper#EXTRA_THEME}. This is the dark
+ * variant of the theme used in setup wizard for O DR.
+ */
+ public static final String THEME_GLIF_V2 = "glif_v2";
+
+ /**
+ * Passed in a setup wizard intent as {@link WizardManagerHelper#EXTRA_THEME}. This is the default
+ * theme used in setup wizard for O DR.
+ */
+ public static final String THEME_GLIF_V2_LIGHT = "glif_v2_light";
+
+ /**
+ * Passed in a setup wizard intent as {@link WizardManagerHelper#EXTRA_THEME}. This is the dark
+ * variant of the theme used in setup wizard for P.
+ */
+ public static final String THEME_GLIF_V3 = "glif_v3";
+
+ /**
+ * Passed in a setup wizard intent as {@link WizardManagerHelper#EXTRA_THEME}. This is the default
+ * theme used in setup wizard for P.
+ */
+ public static final String THEME_GLIF_V3_LIGHT = "glif_v3_light";
+
+ public static final String THEME_HOLO = "holo";
+ public static final String THEME_HOLO_LIGHT = "holo_light";
+ public static final String THEME_MATERIAL = "material";
+ public static final String THEME_MATERIAL_LIGHT = "material_light";
+
+ /**
+ * Checks the intent whether the extra indicates that the light theme should be used or not. If
+ * the theme is not specified in the intent, or the theme specified is unknown, the value def will
+ * be returned. Note that day-night themes are not taken into account by this method.
+ *
+ * @param intent The intent used to start the activity, which the theme extra will be read from.
+ * @param def The default value if the theme is not specified.
+ * @return True if the activity started by the given intent should use light theme.
+ */
+ public static boolean isLightTheme(Intent intent, boolean def) {
+ final String theme = intent.getStringExtra(WizardManagerHelper.EXTRA_THEME);
+ return isLightTheme(theme, def);
+ }
+
+ /**
+ * Checks whether {@code theme} represents a light or dark theme. If the theme specified is
+ * unknown, the value def will be returned. Note that day-night themes are not taken into account
+ * by this method.
+ *
+ * @param theme The theme as specified from an intent sent from setup wizard.
+ * @param def The default value if the theme is not known.
+ * @return True if {@code theme} represents a light theme.
+ */
+ public static boolean isLightTheme(String theme, boolean def) {
+ if (THEME_HOLO_LIGHT.equals(theme)
+ || THEME_MATERIAL_LIGHT.equals(theme)
+ || THEME_GLIF_LIGHT.equals(theme)
+ || THEME_GLIF_V2_LIGHT.equals(theme)
+ || THEME_GLIF_V3_LIGHT.equals(theme)) {
+ return true;
+ } else if (THEME_HOLO.equals(theme)
+ || THEME_MATERIAL.equals(theme)
+ || THEME_GLIF.equals(theme)
+ || THEME_GLIF_V2.equals(theme)
+ || THEME_GLIF_V3.equals(theme)) {
+ return false;
+ } else {
+ return def;
+ }
+ }
+
+ /**
+ * Reads the theme from the intent, and applies the theme to the activity as resolved by {@link
+ * ThemeResolver#getDefault()}.
+ *
+ * <p>If you require extra theme attributes, consider overriding {@link
+ * android.app.Activity#onApplyThemeResource} in your activity and call {@link
+ * android.content.res.Resources.Theme#applyStyle(int, boolean)} using your theme overlay.
+ *
+ * <pre>{@code
+ * protected void onApplyThemeResource(Theme theme, int resid, boolean first) {
+ * super.onApplyThemeResource(theme, resid, first);
+ * theme.applyStyle(R.style.MyThemeOverlay, true);
+ * }
+ * }</pre>
+ *
+ * @param activity the activity to get the intent from and apply the resulting theme to.
+ */
+ public static void applyTheme(Activity activity) {
+ ThemeResolver.getDefault().applyTheme(activity);
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/util/ThemeResolver.java b/main/src/com/google/android/setupdesign/util/ThemeResolver.java
new file mode 100644
index 0000000..2870af9
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/util/ThemeResolver.java
@@ -0,0 +1,237 @@
+/*
+ * 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.google.android.setupdesign.util;
+
+import android.app.Activity;
+import android.content.Intent;
+import androidx.annotation.Nullable;
+import androidx.annotation.StyleRes;
+import com.google.android.setupcompat.util.WizardManagerHelper;
+import com.google.android.setupdesign.R;
+
+/**
+ * A resolver to resolve the theme from a string or an activity intent, setting options like the
+ * default theme and the oldest supported theme. Apps can share the resolver across the entire
+ * process by calling {@link #setDefault(ThemeResolver)} in {@link
+ * android.app.Application#onCreate()}. If an app needs more granular sharing of the theme default
+ * values, additional instances of {@link ThemeResolver} can be created using the builder.
+ */
+public class ThemeResolver {
+
+ @StyleRes private final int defaultTheme;
+ @Nullable private final String oldestSupportedTheme;
+ private final boolean useDayNight;
+
+ @Nullable private static ThemeResolver defaultResolver;
+
+ /**
+ * Sets the default instance used for the whole process. Can be null to reset the default to the
+ * preset one.
+ */
+ public static void setDefault(@Nullable ThemeResolver resolver) {
+ defaultResolver = resolver;
+ }
+
+ /**
+ * Returns the default instance, which can be changed using {@link #setDefault(ThemeResolver)}.
+ */
+ public static ThemeResolver getDefault() {
+ if (defaultResolver == null) {
+ defaultResolver =
+ new ThemeResolver.Builder()
+ .setDefaultTheme(R.style.SuwThemeGlif_DayNight)
+ .setUseDayNight(true)
+ .build();
+ }
+ return defaultResolver;
+ }
+
+ private ThemeResolver(
+ int defaultTheme, @Nullable String oldestSupportedTheme, boolean useDayNight) {
+ this.defaultTheme = defaultTheme;
+ this.oldestSupportedTheme = oldestSupportedTheme;
+ this.useDayNight = useDayNight;
+ }
+
+ /**
+ * Returns the style for the theme specified in the intent extra. If the specified string theme is
+ * older than the oldest supported theme, the default will be returned instead. Note that the
+ * default theme is returned without processing -- it may not be a DayNight theme even if {@link
+ * #useDayNight} is true.
+ */
+ @StyleRes
+ public int resolve(Intent intent) {
+ return resolve(
+ intent.getStringExtra(WizardManagerHelper.EXTRA_THEME),
+ /* suppressDayNight= */ WizardManagerHelper.isSetupWizardIntent(intent));
+ }
+
+ /**
+ * Returns the style for the given string theme. If the specified string theme is older than the
+ * oldest supported theme, the default will be returned instead. Note that the default theme is
+ * returned without processing -- it may not be a DayNight theme even if {@link #useDayNight} is
+ * true.
+ */
+ @StyleRes
+ public int resolve(@Nullable String theme) {
+ return resolve(theme, /* suppressDayNight= */ false);
+ }
+
+ @StyleRes
+ private int resolve(@Nullable String theme, boolean suppressDayNight) {
+ int themeResource =
+ useDayNight && !suppressDayNight ? getDayNightThemeRes(theme) : getThemeRes(theme);
+ if (themeResource == 0) {
+ return defaultTheme;
+ }
+
+ if (oldestSupportedTheme != null && compareThemes(theme, oldestSupportedTheme) < 0) {
+ return defaultTheme;
+ }
+ return themeResource;
+ }
+
+ /** Reads the theme from the intent, and applies the resolved theme to the activity. */
+ public void applyTheme(Activity activity) {
+ activity.setTheme(resolve(activity.getIntent()));
+ }
+
+ /**
+ * Returns the corresponding DayNight theme resource ID for the given string theme. DayNight
+ * themes are themes that will be either light or dark depending on the system setting. For
+ * example, the string {@link ThemeHelper#THEME_GLIF_LIGHT} will return
+ * {@code @style/SuwThemeGlif.DayNight}.
+ */
+ @StyleRes
+ private static int getDayNightThemeRes(@Nullable String theme) {
+ if (theme != null) {
+ switch (theme) {
+ case ThemeHelper.THEME_GLIF_V3_LIGHT:
+ case ThemeHelper.THEME_GLIF_V3:
+ return R.style.SuwThemeGlifV3_DayNight;
+ case ThemeHelper.THEME_GLIF_V2_LIGHT:
+ case ThemeHelper.THEME_GLIF_V2:
+ return R.style.SuwThemeGlifV2_DayNight;
+ case ThemeHelper.THEME_GLIF_LIGHT:
+ case ThemeHelper.THEME_GLIF:
+ return R.style.SuwThemeGlif_DayNight;
+ case ThemeHelper.THEME_MATERIAL_LIGHT:
+ case ThemeHelper.THEME_MATERIAL:
+ return R.style.SuwThemeMaterial_DayNight;
+ default:
+ // fall through
+ }
+ }
+ return 0;
+ }
+
+ /**
+ * Returns the theme resource ID for the given string theme. For example, the string {@link
+ * ThemeHelper#THEME_GLIF_LIGHT} will return {@code @style/SuwThemeGlif.Light}.
+ */
+ @StyleRes
+ private static int getThemeRes(@Nullable String theme) {
+ if (theme != null) {
+ switch (theme) {
+ case ThemeHelper.THEME_GLIF_V3_LIGHT:
+ return R.style.SuwThemeGlifV3_Light;
+ case ThemeHelper.THEME_GLIF_V3:
+ return R.style.SuwThemeGlifV3;
+ case ThemeHelper.THEME_GLIF_V2_LIGHT:
+ return R.style.SuwThemeGlifV2_Light;
+ case ThemeHelper.THEME_GLIF_V2:
+ return R.style.SuwThemeGlifV2;
+ case ThemeHelper.THEME_GLIF_LIGHT:
+ return R.style.SuwThemeGlif_Light;
+ case ThemeHelper.THEME_GLIF:
+ return R.style.SuwThemeGlif;
+ case ThemeHelper.THEME_MATERIAL_LIGHT:
+ return R.style.SuwThemeMaterial_Light;
+ case ThemeHelper.THEME_MATERIAL:
+ return R.style.SuwThemeMaterial;
+ default:
+ // fall through
+ }
+ }
+ return 0;
+ }
+
+ /** Compares whether the versions of {@code theme1} and {@code theme2} to check which is newer. */
+ private static int compareThemes(String theme1, String theme2) {
+ return Integer.valueOf(getThemeVersion(theme1)).compareTo(getThemeVersion(theme2));
+ }
+
+ /**
+ * Returns the version of the theme. The absolute number of the theme version is not defined, but
+ * a larger number in the version indicates a newer theme.
+ */
+ private static int getThemeVersion(String theme) {
+ if (theme != null) {
+ switch (theme) {
+ case ThemeHelper.THEME_GLIF_V3_LIGHT:
+ case ThemeHelper.THEME_GLIF_V3:
+ return 4;
+ case ThemeHelper.THEME_GLIF_V2_LIGHT:
+ case ThemeHelper.THEME_GLIF_V2:
+ return 3;
+ case ThemeHelper.THEME_GLIF_LIGHT:
+ case ThemeHelper.THEME_GLIF:
+ return 2;
+ case ThemeHelper.THEME_MATERIAL_LIGHT:
+ case ThemeHelper.THEME_MATERIAL:
+ return 1;
+ default:
+ // fall through
+ }
+ }
+ return -1;
+ }
+
+ /** Builder class for {@link ThemeResolver}. */
+ public static class Builder {
+ @StyleRes private int defaultTheme = R.style.SuwThemeGlif_DayNight;
+ @Nullable private String oldestSupportedTheme = null;
+ private boolean useDayNight = true;
+
+ public Builder() {}
+
+ public Builder(ThemeResolver themeResolver) {
+ this.defaultTheme = themeResolver.defaultTheme;
+ this.oldestSupportedTheme = themeResolver.oldestSupportedTheme;
+ this.useDayNight = themeResolver.useDayNight;
+ }
+
+ public Builder setDefaultTheme(@StyleRes int defaultTheme) {
+ this.defaultTheme = defaultTheme;
+ return this;
+ }
+
+ public Builder setOldestSupportedTheme(String oldestSupportedTheme) {
+ this.oldestSupportedTheme = oldestSupportedTheme;
+ return this;
+ }
+
+ public Builder setUseDayNight(boolean useDayNight) {
+ this.useDayNight = useDayNight;
+ return this;
+ }
+
+ public ThemeResolver build() {
+ return new ThemeResolver(defaultTheme, oldestSupportedTheme, useDayNight);
+ }
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/view/BottomScrollView.java b/main/src/com/google/android/setupdesign/view/BottomScrollView.java
new file mode 100644
index 0000000..83527b0
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/view/BottomScrollView.java
@@ -0,0 +1,109 @@
+/*
+ * Copyright (C) 2015 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.google.android.setupdesign.view;
+
+import android.content.Context;
+import androidx.annotation.VisibleForTesting;
+import android.util.AttributeSet;
+import android.view.View;
+import android.widget.ScrollView;
+
+/**
+ * An extension of ScrollView that will invoke a listener callback when the ScrollView needs
+ * scrolling, and when the ScrollView is being scrolled to the bottom. This is often used in Setup
+ * Wizard as a way to ensure that users see all the content before proceeding.
+ */
+public class BottomScrollView extends ScrollView {
+
+ public interface BottomScrollListener {
+ void onScrolledToBottom();
+
+ void onRequiresScroll();
+ }
+
+ private BottomScrollListener listener;
+ private int scrollThreshold;
+ private boolean requiringScroll = false;
+
+ private final Runnable checkScrollRunnable =
+ new Runnable() {
+ @Override
+ public void run() {
+ checkScroll();
+ }
+ };
+
+ public BottomScrollView(Context context) {
+ super(context);
+ }
+
+ public BottomScrollView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public BottomScrollView(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ }
+
+ public void setBottomScrollListener(BottomScrollListener l) {
+ listener = l;
+ }
+
+ @VisibleForTesting
+ public BottomScrollListener getBottomScrollListener() {
+ return listener;
+ }
+
+ @VisibleForTesting
+ public int getScrollThreshold() {
+ return scrollThreshold;
+ }
+
+ @Override
+ protected void onLayout(boolean changed, int l, int t, int r, int b) {
+ super.onLayout(changed, l, t, r, b);
+ final View child = getChildAt(0);
+ if (child != null) {
+ scrollThreshold = Math.max(0, child.getMeasuredHeight() - b + t - getPaddingBottom());
+ }
+ if (b - t > 0) {
+ // Post check scroll in the next run loop, so that the callback methods will be invoked
+ // after the layout pass. This way a new layout pass will be scheduled if view
+ // properties are changed in the callbacks.
+ post(checkScrollRunnable);
+ }
+ }
+
+ @Override
+ protected void onScrollChanged(int l, int t, int oldl, int oldt) {
+ super.onScrollChanged(l, t, oldl, oldt);
+ if (oldt != t) {
+ checkScroll();
+ }
+ }
+
+ private void checkScroll() {
+ if (listener != null) {
+ if (getScrollY() >= scrollThreshold) {
+ listener.onScrolledToBottom();
+ } else if (!requiringScroll) {
+ requiringScroll = true;
+ listener.onRequiresScroll();
+ }
+ }
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/view/ButtonBarLayout.java b/main/src/com/google/android/setupdesign/view/ButtonBarLayout.java
new file mode 100644
index 0000000..add436a
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/view/ButtonBarLayout.java
@@ -0,0 +1,118 @@
+/*
+ * Copyright (C) 2017 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.google.android.setupdesign.view;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.View;
+import android.widget.LinearLayout;
+import com.google.android.setupdesign.R;
+
+/**
+ * An extension of LinearLayout that automatically switches to vertical orientation when it can't
+ * fit its child views horizontally.
+ *
+ * <p>Modified from {@code com.android.internal.widget.ButtonBarLayout}
+ */
+public class ButtonBarLayout extends LinearLayout {
+
+ private boolean stacked = false;
+ private int originalPaddingLeft;
+ private int originalPaddingRight;
+
+ public ButtonBarLayout(Context context) {
+ super(context);
+ }
+
+ public ButtonBarLayout(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ final int widthSize = MeasureSpec.getSize(widthMeasureSpec);
+
+ setStacked(false);
+
+ boolean needsRemeasure = false;
+
+ int initialWidthMeasureSpec = widthMeasureSpec;
+ if (MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.EXACTLY) {
+ // Measure with WRAP_CONTENT, so that we can compare the measured size with the
+ // available size to see if we need to stack.
+ initialWidthMeasureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
+
+ // We'll need to remeasure again to fill excess space.
+ needsRemeasure = true;
+ }
+
+ super.onMeasure(initialWidthMeasureSpec, heightMeasureSpec);
+
+ if (getMeasuredWidth() > widthSize) {
+ setStacked(true);
+
+ // Measure again in the new orientation.
+ needsRemeasure = true;
+ }
+
+ if (needsRemeasure) {
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+ }
+ }
+
+ private void setStacked(boolean stacked) {
+ if (this.stacked == stacked) {
+ return;
+ }
+ this.stacked = stacked;
+ int childCount = getChildCount();
+ for (int i = 0; i < childCount; i++) {
+ View child = getChildAt(i);
+ LayoutParams childParams = (LayoutParams) child.getLayoutParams();
+ if (stacked) {
+ child.setTag(R.id.suw_original_weight, childParams.weight);
+ childParams.weight = 0;
+ } else {
+ Float weight = (Float) child.getTag(R.id.suw_original_weight);
+ if (weight != null) {
+ childParams.weight = weight;
+ }
+ }
+ child.setLayoutParams(childParams);
+ }
+
+ setOrientation(stacked ? LinearLayout.VERTICAL : LinearLayout.HORIZONTAL);
+
+ // Reverse the child order, so that the primary button is towards the top when vertical
+ for (int i = childCount - 1; i >= 0; i--) {
+ bringChildToFront(getChildAt(i));
+ }
+
+ if (stacked) {
+ // HACK: In the default button bar style, the left and right paddings are not
+ // balanced to compensate for different alignment for borderless (left) button and
+ // the raised (right) button. When it's stacked, we want the buttons to be centered,
+ // so we balance out the paddings here.
+ originalPaddingLeft = getPaddingLeft();
+ originalPaddingRight = getPaddingRight();
+ int paddingHorizontal = Math.max(originalPaddingLeft, originalPaddingRight);
+ setPadding(paddingHorizontal, getPaddingTop(), paddingHorizontal, getPaddingBottom());
+ } else {
+ setPadding(originalPaddingLeft, getPaddingTop(), originalPaddingRight, getPaddingBottom());
+ }
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/view/CheckableLinearLayout.java b/main/src/com/google/android/setupdesign/view/CheckableLinearLayout.java
new file mode 100644
index 0000000..b12a20f
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/view/CheckableLinearLayout.java
@@ -0,0 +1,85 @@
+/*
+ * Copyright (C) 2017 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.google.android.setupdesign.view;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.os.Build.VERSION_CODES;
+import androidx.annotation.Nullable;
+import android.util.AttributeSet;
+import android.widget.Checkable;
+import android.widget.LinearLayout;
+
+/**
+ * A LinearLayout which is checkable. This will set the checked state when {@link
+ * #onCreateDrawableState(int)} is called, and can be used with {@code android:duplicateParentState}
+ * to propagate the drawable state to child views.
+ */
+public class CheckableLinearLayout extends LinearLayout implements Checkable {
+
+ private boolean checked = false;
+
+ public CheckableLinearLayout(Context context) {
+ super(context);
+ }
+
+ public CheckableLinearLayout(Context context, @Nullable AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @TargetApi(VERSION_CODES.HONEYCOMB)
+ public CheckableLinearLayout(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+
+ @TargetApi(VERSION_CODES.LOLLIPOP)
+ public CheckableLinearLayout(
+ Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ }
+
+ {
+ setFocusable(true);
+ }
+
+ @Override
+ protected int[] onCreateDrawableState(int extraSpace) {
+ if (this.checked) {
+ final int[] superStates = super.onCreateDrawableState(extraSpace + 1);
+ final int[] checked = new int[] {android.R.attr.state_checked};
+ return mergeDrawableStates(superStates, checked);
+ } else {
+ return super.onCreateDrawableState(extraSpace);
+ }
+ }
+
+ @Override
+ public void setChecked(boolean checked) {
+ this.checked = checked;
+ refreshDrawableState();
+ }
+
+ @Override
+ public boolean isChecked() {
+ return checked;
+ }
+
+ @Override
+ public void toggle() {
+ setChecked(!isChecked());
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/view/FillContentLayout.java b/main/src/com/google/android/setupdesign/view/FillContentLayout.java
new file mode 100644
index 0000000..d11333b
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/view/FillContentLayout.java
@@ -0,0 +1,122 @@
+/*
+ * Copyright (C) 2017 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.google.android.setupdesign.view;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.util.AttributeSet;
+import android.view.View;
+import android.widget.FrameLayout;
+import com.google.android.setupdesign.R;
+
+/**
+ * A layout that will measure its children size based on the space it is given, by using its {@code
+ * android:minWidth}, {@code android:minHeight}, {@code android:maxWidth}, and {@code
+ * android:maxHeight} values.
+ *
+ * <p>Typically this is used to show an illustration image or video on the screen. For optimal UX,
+ * those assets typically want to occupy the remaining space available on screen within a certain
+ * range, and then stop scaling beyond the min/max size attributes. Therefore this view is typically
+ * used inside a ScrollView with {@code fillViewport} set to true, together with a linear layout
+ * weight or relative layout to fill the remaining space visible on screen.
+ *
+ * <p>When measuring, this view ignores its children and simply layout according to the minWidth /
+ * minHeight given. Therefore it is common for children of this layout to have width / height set to
+ * {@code match_parent}. The maxWidth / maxHeight values will then be applied to the children to
+ * make sure they are not too big.
+ */
+public class FillContentLayout extends FrameLayout {
+
+ private int maxWidth;
+ private int maxHeight;
+
+ public FillContentLayout(Context context) {
+ this(context, null);
+ }
+
+ public FillContentLayout(Context context, AttributeSet attrs) {
+ this(context, attrs, R.attr.suwFillContentLayoutStyle);
+ }
+
+ public FillContentLayout(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ init(context, attrs, defStyleAttr);
+ }
+
+ private void init(Context context, AttributeSet attrs, int defStyleAttr) {
+ TypedArray a =
+ context.obtainStyledAttributes(attrs, R.styleable.SuwFillContentLayout, defStyleAttr, 0);
+
+ maxHeight = a.getDimensionPixelSize(R.styleable.SuwFillContentLayout_android_maxHeight, -1);
+ maxWidth = a.getDimensionPixelSize(R.styleable.SuwFillContentLayout_android_maxWidth, -1);
+
+ a.recycle();
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ // Measure this view with the minWidth and minHeight, without asking the children.
+ // (Children size is the drawable's intrinsic size, and we don't want that to influence
+ // the size of the illustration).
+ setMeasuredDimension(
+ getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),
+ getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
+
+ int childCount = getChildCount();
+ for (int i = 0; i < childCount; i++) {
+ measureIllustrationChild(getChildAt(i), getMeasuredWidth(), getMeasuredHeight());
+ }
+ }
+
+ private void measureIllustrationChild(View child, int parentWidth, int parentHeight) {
+ // Modified from ViewGroup#measureChildWithMargins
+ final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
+
+ // Create measure specs that are no bigger than min(parentSize, maxSize)
+
+ int childWidthMeasureSpec =
+ getMaxSizeMeasureSpec(
+ Math.min(maxWidth, parentWidth),
+ getPaddingLeft() + getPaddingRight() + lp.leftMargin + lp.rightMargin,
+ lp.width);
+ int childHeightMeasureSpec =
+ getMaxSizeMeasureSpec(
+ Math.min(maxHeight, parentHeight),
+ getPaddingTop() + getPaddingBottom() + lp.topMargin + lp.bottomMargin,
+ lp.height);
+
+ child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
+ }
+
+ private static int getMaxSizeMeasureSpec(int maxSize, int padding, int childDimension) {
+ // Modified from ViewGroup#getChildMeasureSpec
+ int size = Math.max(0, maxSize - padding);
+
+ if (childDimension >= 0) {
+ // Child wants a specific size... so be it
+ return MeasureSpec.makeMeasureSpec(childDimension, MeasureSpec.EXACTLY);
+ } else if (childDimension == LayoutParams.MATCH_PARENT) {
+ // Child wants to be our size. So be it.
+ return MeasureSpec.makeMeasureSpec(size, MeasureSpec.EXACTLY);
+ } else if (childDimension == LayoutParams.WRAP_CONTENT) {
+ // Child wants to determine its own size. It can't be
+ // bigger than us.
+ return MeasureSpec.makeMeasureSpec(size, MeasureSpec.AT_MOST);
+ }
+ return 0;
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/view/HeaderRecyclerView.java b/main/src/com/google/android/setupdesign/view/HeaderRecyclerView.java
new file mode 100644
index 0000000..c5f2593
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/view/HeaderRecyclerView.java
@@ -0,0 +1,275 @@
+/*
+ * Copyright (C) 2015 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.google.android.setupdesign.view;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.os.Build;
+import androidx.recyclerview.widget.RecyclerView;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.accessibility.AccessibilityEvent;
+import android.widget.FrameLayout;
+import com.google.android.setupdesign.DividerItemDecoration;
+import com.google.android.setupdesign.R;
+
+/**
+ * A RecyclerView that can display a header item at the start of the list. The header can be set by
+ * {@code app:suwHeader} in XML. Note that the header will not be inflated until a layout manager is
+ * set.
+ */
+public class HeaderRecyclerView extends RecyclerView {
+
+ private static class HeaderViewHolder extends ViewHolder
+ implements DividerItemDecoration.DividedViewHolder {
+
+ HeaderViewHolder(View itemView) {
+ super(itemView);
+ }
+
+ @Override
+ public boolean isDividerAllowedAbove() {
+ return false;
+ }
+
+ @Override
+ public boolean isDividerAllowedBelow() {
+ return false;
+ }
+ }
+
+ /**
+ * An adapter that can optionally add one header item to the RecyclerView.
+ *
+ * @param <CVH> Type of the content view holder. i.e. view holder type of the wrapped adapter.
+ */
+ public static class HeaderAdapter<CVH extends ViewHolder>
+ extends RecyclerView.Adapter<ViewHolder> {
+
+ private static final int HEADER_VIEW_TYPE = Integer.MAX_VALUE;
+
+ private final RecyclerView.Adapter<CVH> adapter;
+ private View header;
+
+ private final AdapterDataObserver observer =
+ new AdapterDataObserver() {
+
+ @Override
+ public void onChanged() {
+ notifyDataSetChanged();
+ }
+
+ @Override
+ public void onItemRangeChanged(int positionStart, int itemCount) {
+ if (header != null) {
+ positionStart++;
+ }
+ notifyItemRangeChanged(positionStart, itemCount);
+ }
+
+ @Override
+ public void onItemRangeInserted(int positionStart, int itemCount) {
+ if (header != null) {
+ positionStart++;
+ }
+ notifyItemRangeInserted(positionStart, itemCount);
+ }
+
+ @Override
+ public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) {
+ if (header != null) {
+ fromPosition++;
+ toPosition++;
+ }
+ // Why is there no notifyItemRangeMoved?
+ for (int i = 0; i < itemCount; i++) {
+ notifyItemMoved(fromPosition + i, toPosition + i);
+ }
+ }
+
+ @Override
+ public void onItemRangeRemoved(int positionStart, int itemCount) {
+ if (header != null) {
+ positionStart++;
+ }
+ notifyItemRangeRemoved(positionStart, itemCount);
+ }
+ };
+
+ public HeaderAdapter(RecyclerView.Adapter<CVH> adapter) {
+ this.adapter = adapter;
+ this.adapter.registerAdapterDataObserver(observer);
+ setHasStableIds(this.adapter.hasStableIds());
+ }
+
+ @Override
+ public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
+ // Returning the same view (header) results in crash ".. but view is not a real child."
+ // The framework creates more than one instance of header because of "disappear"
+ // animations applied on the header and this necessitates creation of another header
+ // view to use after the animation. We work around this restriction by returning an
+ // empty FrameLayout to which the header is attached using #onBindViewHolder method.
+ if (viewType == HEADER_VIEW_TYPE) {
+ FrameLayout frameLayout = new FrameLayout(parent.getContext());
+ FrameLayout.LayoutParams params =
+ new FrameLayout.LayoutParams(
+ FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT);
+ frameLayout.setLayoutParams(params);
+ return new HeaderViewHolder(frameLayout);
+ } else {
+ return adapter.onCreateViewHolder(parent, viewType);
+ }
+ }
+
+ @Override
+ @SuppressWarnings("unchecked") // Non-header position always return type CVH
+ public void onBindViewHolder(ViewHolder holder, int position) {
+ if (header != null) {
+ position--;
+ }
+
+ if (holder instanceof HeaderViewHolder) {
+ if (header == null) {
+ throw new IllegalStateException("HeaderViewHolder cannot find mHeader");
+ }
+ if (header.getParent() != null) {
+ ((ViewGroup) header.getParent()).removeView(header);
+ }
+ FrameLayout mHeaderParent = (FrameLayout) holder.itemView;
+ mHeaderParent.addView(header);
+ } else {
+ adapter.onBindViewHolder((CVH) holder, position);
+ }
+ }
+
+ @Override
+ public int getItemViewType(int position) {
+ if (header != null) {
+ position--;
+ }
+ if (position < 0) {
+ return HEADER_VIEW_TYPE;
+ }
+ return adapter.getItemViewType(position);
+ }
+
+ @Override
+ public int getItemCount() {
+ int count = adapter.getItemCount();
+ if (header != null) {
+ count++;
+ }
+ return count;
+ }
+
+ @Override
+ public long getItemId(int position) {
+ if (header != null) {
+ position--;
+ }
+ if (position < 0) {
+ return Long.MAX_VALUE;
+ }
+ return adapter.getItemId(position);
+ }
+
+ public void setHeader(View header) {
+ this.header = header;
+ }
+
+ public RecyclerView.Adapter<CVH> getWrappedAdapter() {
+ return adapter;
+ }
+ }
+
+ private View header;
+ private int headerRes;
+
+ public HeaderRecyclerView(Context context) {
+ super(context);
+ init(null, 0);
+ }
+
+ public HeaderRecyclerView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init(attrs, 0);
+ }
+
+ public HeaderRecyclerView(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ init(attrs, defStyleAttr);
+ }
+
+ private void init(AttributeSet attrs, int defStyleAttr) {
+ final TypedArray a =
+ getContext()
+ .obtainStyledAttributes(attrs, R.styleable.SuwHeaderRecyclerView, defStyleAttr, 0);
+ headerRes = a.getResourceId(R.styleable.SuwHeaderRecyclerView_suwHeader, 0);
+ a.recycle();
+ }
+
+ @Override
+ public void onInitializeAccessibilityEvent(AccessibilityEvent event) {
+ super.onInitializeAccessibilityEvent(event);
+
+ // Decoration-only headers should not count as an item for accessibility, adjust the
+ // accessibility event to account for that.
+ final int numberOfHeaders = header != null ? 1 : 0;
+ event.setItemCount(event.getItemCount() - numberOfHeaders);
+ event.setFromIndex(Math.max(event.getFromIndex() - numberOfHeaders, 0));
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
+ event.setToIndex(Math.max(event.getToIndex() - numberOfHeaders, 0));
+ }
+ }
+
+ /** Gets the header view of this RecyclerView, or {@code null} if there are no headers. */
+ public View getHeader() {
+ return header;
+ }
+
+ /**
+ * Set the view to use as the header of this recycler view. Note: This must be called before
+ * setAdapter.
+ */
+ public void setHeader(View header) {
+ this.header = header;
+ }
+
+ @Override
+ public void setLayoutManager(LayoutManager layout) {
+ super.setLayoutManager(layout);
+ if (layout != null && header == null && headerRes != 0) {
+ // Inflating a child view requires the layout manager to be set. Check here to see if
+ // any header item is specified in XML and inflate them.
+ final LayoutInflater inflater = LayoutInflater.from(getContext());
+ header = inflater.inflate(headerRes, this, false);
+ }
+ }
+
+ @Override
+ @SuppressWarnings("rawtypes,unchecked") // RecyclerView.setAdapter uses raw type :(
+ public void setAdapter(Adapter adapter) {
+ if (header != null && adapter != null) {
+ final HeaderAdapter headerAdapter = new HeaderAdapter(adapter);
+ headerAdapter.setHeader(header);
+ adapter = headerAdapter;
+ }
+ super.setAdapter(adapter);
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/view/Illustration.java b/main/src/com/google/android/setupdesign/view/Illustration.java
new file mode 100644
index 0000000..2b4924e
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/view/Illustration.java
@@ -0,0 +1,227 @@
+/*
+ * Copyright (C) 2015 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.google.android.setupdesign.view;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.content.pm.ApplicationInfo;
+import android.content.res.TypedArray;
+import android.graphics.Canvas;
+import android.graphics.Rect;
+import android.graphics.drawable.Drawable;
+import android.os.Build.VERSION;
+import android.os.Build.VERSION_CODES;
+import android.util.AttributeSet;
+import android.util.LayoutDirection;
+import android.view.Gravity;
+import android.view.ViewOutlineProvider;
+import android.widget.FrameLayout;
+import com.google.android.setupdesign.R;
+
+/**
+ * Class to draw the illustration of setup wizard. The {@code aspectRatio} attribute determines the
+ * aspect ratio of the top padding, which leaves space for the illustration. Draws the illustration
+ * drawable to fit the width of the view and fills the rest with the background.
+ *
+ * <p>If an aspect ratio is set, then the aspect ratio of the source drawable is maintained.
+ * Otherwise the aspect ratio will be ignored, only increasing the width of the illustration.
+ */
+public class Illustration extends FrameLayout {
+
+ // Size of the baseline grid in pixels
+ private float baselineGridSize;
+ private Drawable background;
+ private Drawable illustration;
+ private final Rect viewBounds = new Rect();
+ private final Rect illustrationBounds = new Rect();
+ private float scale = 1.0f;
+ private float aspectRatio = 0.0f;
+
+ public Illustration(Context context) {
+ super(context);
+ init(null, 0);
+ }
+
+ public Illustration(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init(attrs, 0);
+ }
+
+ @TargetApi(VERSION_CODES.HONEYCOMB)
+ public Illustration(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ init(attrs, defStyleAttr);
+ }
+
+ // All the constructors delegate to this init method. The 3-argument constructor is not
+ // available in FrameLayout before v11, so call super with the exact same arguments.
+ private void init(AttributeSet attrs, int defStyleAttr) {
+ if (attrs != null) {
+ TypedArray a =
+ getContext().obtainStyledAttributes(attrs, R.styleable.SuwIllustration, defStyleAttr, 0);
+ aspectRatio = a.getFloat(R.styleable.SuwIllustration_suwAspectRatio, 0.0f);
+ a.recycle();
+ }
+ // Number of pixels of the 8dp baseline grid as defined in material design specs
+ baselineGridSize = getResources().getDisplayMetrics().density * 8;
+ setWillNotDraw(false);
+ }
+
+ /**
+ * The background will be drawn to fill up the rest of the view. It will also be scaled by the
+ * same amount as the foreground so their textures look the same.
+ */
+ // Override the deprecated setBackgroundDrawable method to support API < 16. View.setBackground
+ // forwards to setBackgroundDrawable in the framework implementation.
+ @SuppressWarnings("deprecation")
+ @Override
+ public void setBackgroundDrawable(Drawable background) {
+ if (background == this.background) {
+ return;
+ }
+ this.background = background;
+ invalidate();
+ requestLayout();
+ }
+
+ /**
+ * Sets the drawable used as the illustration. The drawable is expected to have intrinsic width
+ * and height defined and will be scaled to fit the width of the view.
+ */
+ public void setIllustration(Drawable illustration) {
+ if (illustration == this.illustration) {
+ return;
+ }
+ this.illustration = illustration;
+ invalidate();
+ requestLayout();
+ }
+
+ /**
+ * Set the aspect ratio reserved for the illustration. This overrides the top padding of the view
+ * according to the width of this view and the aspect ratio. Children views will start being laid
+ * out below this aspect ratio.
+ *
+ * @param aspectRatio A float value specifying the aspect ratio (= width / height). 0 to not
+ * override the top padding.
+ */
+ public void setAspectRatio(float aspectRatio) {
+ this.aspectRatio = aspectRatio;
+ invalidate();
+ requestLayout();
+ }
+
+ @Override
+ @Deprecated
+ public void setForeground(Drawable d) {
+ setIllustration(d);
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ if (aspectRatio != 0.0f) {
+ int parentWidth = MeasureSpec.getSize(widthMeasureSpec);
+ int illustrationHeight = (int) (parentWidth / aspectRatio);
+ illustrationHeight = (int) (illustrationHeight - (illustrationHeight % baselineGridSize));
+ setPadding(0, illustrationHeight, 0, 0);
+ }
+ if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) {
+ //noinspection AndroidLintInlinedApi
+ setOutlineProvider(ViewOutlineProvider.BOUNDS);
+ }
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+ }
+
+ @Override
+ protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
+ final int layoutWidth = right - left;
+ final int layoutHeight = bottom - top;
+ if (illustration != null) {
+ int intrinsicWidth = illustration.getIntrinsicWidth();
+ int intrinsicHeight = illustration.getIntrinsicHeight();
+
+ viewBounds.set(0, 0, layoutWidth, layoutHeight);
+ if (aspectRatio != 0f) {
+ scale = layoutWidth / (float) intrinsicWidth;
+ intrinsicWidth = layoutWidth;
+ intrinsicHeight = (int) (intrinsicHeight * scale);
+ }
+ Gravity.apply(
+ Gravity.FILL_HORIZONTAL | Gravity.TOP,
+ intrinsicWidth,
+ intrinsicHeight,
+ viewBounds,
+ illustrationBounds);
+ illustration.setBounds(illustrationBounds);
+ }
+ if (background != null) {
+ // Scale the background bounds by the same scale to compensate for the scale done to the
+ // canvas in onDraw.
+ background.setBounds(
+ 0,
+ 0,
+ (int) Math.ceil(layoutWidth / scale),
+ (int) Math.ceil((layoutHeight - illustrationBounds.height()) / scale));
+ }
+ super.onLayout(changed, left, top, right, bottom);
+ }
+
+ @Override
+ public void onDraw(Canvas canvas) {
+ if (background != null) {
+ // Draw the background filling parts not covered by the illustration
+ canvas.save();
+ canvas.translate(0, illustrationBounds.height());
+ // Scale the background so its size matches the foreground
+ canvas.scale(scale, scale, 0, 0);
+ if (VERSION.SDK_INT > VERSION_CODES.JELLY_BEAN_MR1
+ && shouldMirrorDrawable(background, getLayoutDirection())) {
+ // Flip the illustration for RTL layouts
+ canvas.scale(-1, 1);
+ canvas.translate(-background.getBounds().width(), 0);
+ }
+ background.draw(canvas);
+ canvas.restore();
+ }
+ if (illustration != null) {
+ canvas.save();
+ if (VERSION.SDK_INT > VERSION_CODES.JELLY_BEAN_MR1
+ && shouldMirrorDrawable(illustration, getLayoutDirection())) {
+ // Flip the illustration for RTL layouts
+ canvas.scale(-1, 1);
+ canvas.translate(-illustrationBounds.width(), 0);
+ }
+ // Draw the illustration
+ illustration.draw(canvas);
+ canvas.restore();
+ }
+ super.onDraw(canvas);
+ }
+
+ private boolean shouldMirrorDrawable(Drawable drawable, int layoutDirection) {
+ if (layoutDirection == LayoutDirection.RTL) {
+ if (VERSION.SDK_INT >= VERSION_CODES.KITKAT) {
+ return drawable.isAutoMirrored();
+ } else if (VERSION.SDK_INT >= VERSION_CODES.JELLY_BEAN_MR1) {
+ final int flags = getContext().getApplicationInfo().flags;
+ //noinspection AndroidLintInlinedApi
+ return (flags & ApplicationInfo.FLAG_SUPPORTS_RTL) != 0;
+ }
+ }
+ return false;
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/view/IllustrationVideoView.java b/main/src/com/google/android/setupdesign/view/IllustrationVideoView.java
new file mode 100644
index 0000000..91743c7
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/view/IllustrationVideoView.java
@@ -0,0 +1,352 @@
+/*
+ * Copyright (C) 2017 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.google.android.setupdesign.view;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.SurfaceTexture;
+import android.graphics.drawable.Animatable;
+import android.media.MediaPlayer;
+import android.media.MediaPlayer.OnErrorListener;
+import android.media.MediaPlayer.OnInfoListener;
+import android.media.MediaPlayer.OnPreparedListener;
+import android.media.MediaPlayer.OnSeekCompleteListener;
+import android.net.Uri;
+import android.os.Build.VERSION_CODES;
+import androidx.annotation.Nullable;
+import androidx.annotation.RawRes;
+import androidx.annotation.VisibleForTesting;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.view.Surface;
+import android.view.TextureView;
+import android.view.TextureView.SurfaceTextureListener;
+import android.view.View;
+import com.google.android.setupdesign.R;
+import java.io.IOException;
+
+/**
+ * A view for displaying videos in a continuous loop (without audio). This is typically used for
+ * animated illustrations.
+ *
+ * <p>The video can be specified using {@code app:suwVideo}, specifying the raw resource to the mp4
+ * video. Optionally, {@code app:suwLoopStartMs} can be used to specify which part of the video it
+ * should loop back to
+ *
+ * <p>For optimal file size, use avconv or other video compression tool to remove the unused audio
+ * track and reduce the size of your video asset: avconv -i [input file] -vcodec h264 -crf 20 -an
+ * [output_file]
+ */
+@TargetApi(VERSION_CODES.ICE_CREAM_SANDWICH)
+public class IllustrationVideoView extends TextureView
+ implements Animatable,
+ SurfaceTextureListener,
+ OnPreparedListener,
+ OnSeekCompleteListener,
+ OnInfoListener,
+ OnErrorListener {
+
+ private static final String TAG = "IllustrationVideoView";
+
+ private float aspectRatio = 1.0f; // initial guess until we know
+
+ @Nullable // Can be null when media player fails to initialize
+ protected MediaPlayer mediaPlayer;
+
+ private @RawRes int videoResId = 0;
+
+ private String videoResPackageName;
+
+ @VisibleForTesting Surface surface;
+
+ private boolean prepared;
+
+ public IllustrationVideoView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ final TypedArray a =
+ context.obtainStyledAttributes(attrs, R.styleable.SuwIllustrationVideoView);
+ final int videoResId = a.getResourceId(R.styleable.SuwIllustrationVideoView_suwVideo, 0);
+ a.recycle();
+ setVideoResource(videoResId);
+
+ // By default the video scales without interpolation, resulting in jagged edges in the
+ // video. This works around it by making the view go through scaling, which will apply
+ // anti-aliasing effects.
+ setScaleX(0.9999999f);
+ setScaleX(0.9999999f);
+
+ setSurfaceTextureListener(this);
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ int width = MeasureSpec.getSize(widthMeasureSpec);
+ int height = MeasureSpec.getSize(heightMeasureSpec);
+
+ if (height < width * aspectRatio) {
+ // Height constraint is tighter. Need to scale down the width to fit aspect ratio.
+ width = (int) (height / aspectRatio);
+ } else {
+ // Width constraint is tighter. Need to scale down the height to fit aspect ratio.
+ height = (int) (width * aspectRatio);
+ }
+
+ super.onMeasure(
+ MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
+ MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY));
+ }
+
+ /**
+ * Set the video and video package name to be played by this view.
+ *
+ * @param videoResId Resource ID of the video, typically an MP4 under res/raw.
+ * @param videoResPackageName The package name of videoResId.
+ */
+ public void setVideoResource(@RawRes int videoResId, String videoResPackageName) {
+ if (videoResId != this.videoResId
+ || (videoResPackageName != null && !videoResPackageName.equals(this.videoResPackageName))) {
+ this.videoResId = videoResId;
+ this.videoResPackageName = videoResPackageName;
+ createMediaPlayer();
+ }
+ }
+
+ /**
+ * Set the video to be played by this view.
+ *
+ * @param resId Resource ID of the video, typically an MP4 under res/raw.
+ */
+ public void setVideoResource(@RawRes int resId) {
+ setVideoResource(resId, getContext().getPackageName());
+ }
+
+ @Override
+ public void onWindowFocusChanged(boolean hasWindowFocus) {
+ super.onWindowFocusChanged(hasWindowFocus);
+ if (hasWindowFocus) {
+ start();
+ } else {
+ stop();
+ }
+ }
+
+ /**
+ * Creates a media player for the current URI. The media player will be started immediately if the
+ * view's window is visible. If there is an existing media player, it will be released.
+ */
+ protected void createMediaPlayer() {
+ if (mediaPlayer != null) {
+ mediaPlayer.release();
+ }
+ if (surface == null || videoResId == 0) {
+ return;
+ }
+
+ mediaPlayer = new MediaPlayer();
+
+ mediaPlayer.setSurface(surface);
+ mediaPlayer.setOnPreparedListener(this);
+ mediaPlayer.setOnSeekCompleteListener(this);
+ mediaPlayer.setOnInfoListener(this);
+ mediaPlayer.setOnErrorListener(this);
+
+ setVideoResourceInternal(videoResId, videoResPackageName);
+ }
+
+ private void setVideoResourceInternal(@RawRes int videoRes, String videoResPackageName) {
+ Uri uri = Uri.parse("android.resource://" + videoResPackageName + "/" + videoRes);
+ try {
+ mediaPlayer.setDataSource(getContext(), uri, null);
+ mediaPlayer.prepareAsync();
+ } catch (IOException e) {
+ Log.wtf(TAG, "Unable to set data source", e);
+ }
+ }
+
+ protected void createSurface() {
+ if (surface != null) {
+ surface.release();
+ surface = null;
+ }
+ // Reattach only if it has been previously released
+ SurfaceTexture surfaceTexture = getSurfaceTexture();
+ if (surfaceTexture != null) {
+ setVisibility(View.INVISIBLE);
+ surface = new Surface(surfaceTexture);
+ }
+ }
+
+ @Override
+ protected void onWindowVisibilityChanged(int visibility) {
+ super.onWindowVisibilityChanged(visibility);
+ if (visibility == View.VISIBLE) {
+ reattach();
+ } else {
+ release();
+ }
+ }
+
+ /**
+ * Whether the media player should play the video in a continuous loop. The default value is true.
+ */
+ protected boolean shouldLoop() {
+ return true;
+ }
+
+ /**
+ * Release any resources used by this view. This is automatically called in
+ * onSurfaceTextureDestroyed so in most cases you don't have to call this.
+ */
+ public void release() {
+ if (mediaPlayer != null) {
+ mediaPlayer.release();
+ mediaPlayer = null;
+ prepared = false;
+ }
+ if (surface != null) {
+ surface.release();
+ surface = null;
+ }
+ }
+
+ private void reattach() {
+ if (surface == null) {
+ initVideo();
+ }
+ }
+
+ private void initVideo() {
+ if (getWindowVisibility() != View.VISIBLE) {
+ return;
+ }
+ createSurface();
+ if (surface != null) {
+ createMediaPlayer();
+ } else {
+ Log.w(TAG, "Surface creation failed");
+ }
+ }
+
+ protected void onRenderingStart() {}
+
+ /* SurfaceTextureListener methods */
+
+ @Override
+ public void onSurfaceTextureAvailable(SurfaceTexture surfaceTexture, int width, int height) {
+ // Keep the view hidden until video starts
+ setVisibility(View.INVISIBLE);
+ initVideo();
+ }
+
+ @Override
+ public void onSurfaceTextureSizeChanged(SurfaceTexture surfaceTexture, int width, int height) {}
+
+ @Override
+ public boolean onSurfaceTextureDestroyed(SurfaceTexture surfaceTexture) {
+ release();
+ return true;
+ }
+
+ @Override
+ public void onSurfaceTextureUpdated(SurfaceTexture surfaceTexture) {}
+
+ /* Animatable methods */
+
+ @Override
+ public void start() {
+ if (prepared && mediaPlayer != null && !mediaPlayer.isPlaying()) {
+ mediaPlayer.start();
+ }
+ }
+
+ @Override
+ public void stop() {
+ if (prepared && mediaPlayer != null) {
+ mediaPlayer.pause();
+ }
+ }
+
+ @Override
+ public boolean isRunning() {
+ return mediaPlayer != null && mediaPlayer.isPlaying();
+ }
+
+ /* MediaPlayer callbacks */
+
+ @Override
+ public boolean onInfo(MediaPlayer mp, int what, int extra) {
+ if (what == MediaPlayer.MEDIA_INFO_VIDEO_RENDERING_START) {
+ // Video available, show view now
+ setVisibility(View.VISIBLE);
+ onRenderingStart();
+ }
+ return false;
+ }
+
+ @Override
+ public void onPrepared(MediaPlayer mp) {
+ prepared = true;
+ mp.setLooping(shouldLoop());
+
+ float aspectRatio = 0.0f;
+ if (mp.getVideoWidth() > 0 && mp.getVideoHeight() > 0) {
+ aspectRatio = (float) mp.getVideoHeight() / mp.getVideoWidth();
+ } else {
+ Log.w(TAG, "Unexpected video size=" + mp.getVideoWidth() + "x" + mp.getVideoHeight());
+ }
+ if (Float.compare(this.aspectRatio, aspectRatio) != 0) {
+ this.aspectRatio = aspectRatio;
+ requestLayout();
+ }
+ if (getWindowVisibility() == View.VISIBLE) {
+ start();
+ }
+ }
+
+ @Override
+ public void onSeekComplete(MediaPlayer mp) {
+ if (isPrepared()) {
+ mp.start();
+ } else {
+ Log.wtf(TAG, "Seek complete but media player not prepared");
+ }
+ }
+
+ public int getCurrentPosition() {
+ return mediaPlayer == null ? 0 : mediaPlayer.getCurrentPosition();
+ }
+
+ protected boolean isPrepared() {
+ return prepared;
+ }
+
+ @Override
+ public boolean onError(MediaPlayer mediaPlayer, int what, int extra) {
+ Log.w(TAG, "MediaPlayer error. what=" + what + " extra=" + extra);
+ return false;
+ }
+
+ @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
+ public MediaPlayer getMediaPlayer() {
+ return mediaPlayer;
+ }
+
+ protected float getAspectRatio() {
+ return aspectRatio;
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/view/IntrinsicSizeFrameLayout.java b/main/src/com/google/android/setupdesign/view/IntrinsicSizeFrameLayout.java
new file mode 100644
index 0000000..926f3c9
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/view/IntrinsicSizeFrameLayout.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright (C) 2016 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.google.android.setupdesign.view;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.os.Build.VERSION_CODES;
+import android.util.AttributeSet;
+import android.widget.FrameLayout;
+import com.google.android.setupdesign.R;
+
+/**
+ * A FrameLayout subclass that has an "intrinsic size", which is the size it wants to be if that is
+ * within the constraints given by the parent. The intrinsic size can be set with the {@code
+ * android:width} and {@code android:height} attributes in XML.
+ *
+ * <p>Note that for the intrinsic size to be meaningful, {@code android:layout_width} and/or {@code
+ * android:layout_height} will need to be {@code wrap_content}.
+ */
+public class IntrinsicSizeFrameLayout extends FrameLayout {
+
+ private int intrinsicHeight = 0;
+ private int intrinsicWidth = 0;
+
+ public IntrinsicSizeFrameLayout(Context context) {
+ super(context);
+ init(context, null, 0);
+ }
+
+ public IntrinsicSizeFrameLayout(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init(context, attrs, 0);
+ }
+
+ @TargetApi(VERSION_CODES.HONEYCOMB)
+ public IntrinsicSizeFrameLayout(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ init(context, attrs, defStyleAttr);
+ }
+
+ private void init(Context context, AttributeSet attrs, int defStyleAttr) {
+ final TypedArray a =
+ context.obtainStyledAttributes(
+ attrs, R.styleable.SuwIntrinsicSizeFrameLayout, defStyleAttr, 0);
+ intrinsicHeight =
+ a.getDimensionPixelSize(R.styleable.SuwIntrinsicSizeFrameLayout_android_height, 0);
+ intrinsicWidth =
+ a.getDimensionPixelSize(R.styleable.SuwIntrinsicSizeFrameLayout_android_width, 0);
+ a.recycle();
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ super.onMeasure(
+ getIntrinsicMeasureSpec(widthMeasureSpec, intrinsicWidth),
+ getIntrinsicMeasureSpec(heightMeasureSpec, intrinsicHeight));
+ }
+
+ private int getIntrinsicMeasureSpec(int measureSpec, int intrinsicSize) {
+ if (intrinsicSize <= 0) {
+ // Intrinsic size is not set, just return the original spec
+ return measureSpec;
+ }
+ final int mode = MeasureSpec.getMode(measureSpec);
+ final int size = MeasureSpec.getSize(measureSpec);
+ if (mode == MeasureSpec.UNSPECIFIED) {
+ // Parent did not give any constraint, so we'll be the intrinsic size
+ return MeasureSpec.makeMeasureSpec(intrinsicHeight, MeasureSpec.EXACTLY);
+ } else if (mode == MeasureSpec.AT_MOST) {
+ // If intrinsic size is within parents constraint, take the intrinsic size.
+ // Otherwise take the parents size because that's closest to the intrinsic size.
+ return MeasureSpec.makeMeasureSpec(Math.min(size, intrinsicHeight), MeasureSpec.EXACTLY);
+ }
+ // Parent specified EXACTLY, or in all other cases, just return the original spec
+ return measureSpec;
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/view/NavigationBar.java b/main/src/com/google/android/setupdesign/view/NavigationBar.java
new file mode 100644
index 0000000..9e89ed0
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/view/NavigationBar.java
@@ -0,0 +1,142 @@
+/*
+ * Copyright (C) 2015 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.google.android.setupdesign.view;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.Color;
+import android.os.Build.VERSION_CODES;
+import androidx.annotation.StyleableRes;
+import android.util.AttributeSet;
+import android.view.ContextThemeWrapper;
+import android.view.View;
+import android.widget.Button;
+import android.widget.LinearLayout;
+import com.google.android.setupdesign.R;
+
+/**
+ * Custom navigation bar for use with setup wizard. This bar contains a back button, more button and
+ * next button. By default, the more button is hidden, and typically the next button will be hidden
+ * if the more button is shown.
+ *
+ * @see com.google.android.setupdesign.template.RequireScrollMixin
+ */
+public class NavigationBar extends LinearLayout implements View.OnClickListener {
+
+ /**
+ * An interface to listen to events of the navigation bar, namely when the user clicks on the back
+ * or next button.
+ */
+ public interface NavigationBarListener {
+ void onNavigateBack();
+
+ void onNavigateNext();
+ }
+
+ private static int getNavbarTheme(Context context) {
+ // Normally we can automatically guess the theme by comparing the foreground color against
+ // the background color. But we also allow specifying explicitly using suwNavBarTheme.
+ TypedArray attributes =
+ context.obtainStyledAttributes(
+ new int[] {
+ R.attr.suwNavBarTheme, android.R.attr.colorForeground, android.R.attr.colorBackground
+ });
+ @StyleableRes int suwNavBarTheme = 0;
+ @StyleableRes int colorForeground = 1;
+ @StyleableRes int colorBackground = 2;
+ int theme = attributes.getResourceId(suwNavBarTheme, 0);
+ if (theme == 0) {
+ // Compare the value of the foreground against the background color to see if current
+ // theme is light-on-dark or dark-on-light.
+ float[] foregroundHsv = new float[3];
+ float[] backgroundHsv = new float[3];
+ Color.colorToHSV(attributes.getColor(colorForeground, 0), foregroundHsv);
+ Color.colorToHSV(attributes.getColor(colorBackground, 0), backgroundHsv);
+ boolean isDarkBg = foregroundHsv[2] > backgroundHsv[2];
+ theme = isDarkBg ? R.style.SuwNavBarThemeDark : R.style.SuwNavBarThemeLight;
+ }
+ attributes.recycle();
+ return theme;
+ }
+
+ private static Context getThemedContext(Context context) {
+ final int theme = getNavbarTheme(context);
+ return new ContextThemeWrapper(context, theme);
+ }
+
+ private Button nextButton;
+ private Button backButton;
+ private Button moreButton;
+ private NavigationBarListener listener;
+
+ public NavigationBar(Context context) {
+ super(getThemedContext(context));
+ init();
+ }
+
+ public NavigationBar(Context context, AttributeSet attrs) {
+ super(getThemedContext(context), attrs);
+ init();
+ }
+
+ @TargetApi(VERSION_CODES.HONEYCOMB)
+ public NavigationBar(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(getThemedContext(context), attrs, defStyleAttr);
+ init();
+ }
+
+ // All the constructors delegate to this init method. The 3-argument constructor is not
+ // available in LinearLayout before v11, so call super with the exact same arguments.
+ private void init() {
+ View.inflate(getContext(), R.layout.suw_navbar_view, this);
+ nextButton = (Button) findViewById(R.id.suw_navbar_next);
+ backButton = (Button) findViewById(R.id.suw_navbar_back);
+ moreButton = (Button) findViewById(R.id.suw_navbar_more);
+ }
+
+ public Button getBackButton() {
+ return backButton;
+ }
+
+ public Button getNextButton() {
+ return nextButton;
+ }
+
+ public Button getMoreButton() {
+ return moreButton;
+ }
+
+ public void setNavigationBarListener(NavigationBarListener listener) {
+ this.listener = listener;
+ if (this.listener != null) {
+ getBackButton().setOnClickListener(this);
+ getNextButton().setOnClickListener(this);
+ }
+ }
+
+ @Override
+ public void onClick(View view) {
+ if (listener != null) {
+ if (view == getBackButton()) {
+ listener.onNavigateBack();
+ } else if (view == getNextButton()) {
+ listener.onNavigateNext();
+ }
+ }
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/view/NavigationBarButton.java b/main/src/com/google/android/setupdesign/view/NavigationBarButton.java
new file mode 100644
index 0000000..44a5b85
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/view/NavigationBarButton.java
@@ -0,0 +1,177 @@
+/*
+ * Copyright (C) 2015 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.google.android.setupdesign.view;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.content.res.ColorStateList;
+import android.graphics.PorterDuff;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.LayerDrawable;
+import android.os.Build;
+import androidx.annotation.NonNull;
+import android.util.AttributeSet;
+import android.widget.Button;
+
+/**
+ * Button for navigation bar, which includes tinting of its compound drawables to be used for dark
+ * and light themes.
+ */
+@SuppressLint("AppCompatCustomView")
+public class NavigationBarButton extends Button {
+
+ public NavigationBarButton(Context context) {
+ super(context);
+ init();
+ }
+
+ public NavigationBarButton(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init();
+ }
+
+ private void init() {
+ // Unfortunately, drawableStart and drawableEnd set through XML does not call the setter,
+ // so manually getting it and wrapping it in the compat drawable.
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
+ Drawable[] drawables = getCompoundDrawablesRelative();
+ for (int i = 0; i < drawables.length; i++) {
+ if (drawables[i] != null) {
+ drawables[i] = TintedDrawable.wrap(drawables[i]);
+ }
+ }
+ setCompoundDrawablesRelativeWithIntrinsicBounds(
+ drawables[0], drawables[1], drawables[2], drawables[3]);
+ }
+ }
+
+ @Override
+ public void setCompoundDrawables(Drawable left, Drawable top, Drawable right, Drawable bottom) {
+ if (left != null) {
+ left = TintedDrawable.wrap(left);
+ }
+ if (top != null) {
+ top = TintedDrawable.wrap(top);
+ }
+ if (right != null) {
+ right = TintedDrawable.wrap(right);
+ }
+ if (bottom != null) {
+ bottom = TintedDrawable.wrap(bottom);
+ }
+ super.setCompoundDrawables(left, top, right, bottom);
+ tintDrawables();
+ }
+
+ @Override
+ public void setCompoundDrawablesRelative(
+ Drawable start, Drawable top, Drawable end, Drawable bottom) {
+ if (start != null) {
+ start = TintedDrawable.wrap(start);
+ }
+ if (top != null) {
+ top = TintedDrawable.wrap(top);
+ }
+ if (end != null) {
+ end = TintedDrawable.wrap(end);
+ }
+ if (bottom != null) {
+ bottom = TintedDrawable.wrap(bottom);
+ }
+ super.setCompoundDrawablesRelative(start, top, end, bottom);
+ tintDrawables();
+ }
+
+ @Override
+ public void setTextColor(ColorStateList colors) {
+ super.setTextColor(colors);
+ tintDrawables();
+ }
+
+ private void tintDrawables() {
+ final ColorStateList textColors = getTextColors();
+ if (textColors != null) {
+ for (Drawable drawable : getAllCompoundDrawables()) {
+ if (drawable instanceof TintedDrawable) {
+ ((TintedDrawable) drawable).setTintListCompat(textColors);
+ }
+ }
+ invalidate();
+ }
+ }
+
+ private Drawable[] getAllCompoundDrawables() {
+ Drawable[] drawables = new Drawable[6];
+ Drawable[] compoundDrawables = getCompoundDrawables();
+ drawables[0] = compoundDrawables[0]; // left
+ drawables[1] = compoundDrawables[1]; // top
+ drawables[2] = compoundDrawables[2]; // right
+ drawables[3] = compoundDrawables[3]; // bottom
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
+ Drawable[] compoundDrawablesRelative = getCompoundDrawablesRelative();
+ drawables[4] = compoundDrawablesRelative[0]; // start
+ drawables[5] = compoundDrawablesRelative[2]; // end
+ }
+ return drawables;
+ }
+
+ // TODO: Remove this class and use DrawableCompat.wrap() once we can use support library 22.1.0
+ // or above
+ private static class TintedDrawable extends LayerDrawable {
+
+ public static TintedDrawable wrap(Drawable drawable) {
+ if (drawable instanceof TintedDrawable) {
+ return (TintedDrawable) drawable;
+ }
+ return new TintedDrawable(drawable.mutate());
+ }
+
+ private ColorStateList tintList = null;
+
+ TintedDrawable(Drawable wrapped) {
+ super(new Drawable[] {wrapped});
+ }
+
+ @Override
+ public boolean isStateful() {
+ return true;
+ }
+
+ @Override
+ public boolean setState(@NonNull int[] stateSet) {
+ boolean needsInvalidate = super.setState(stateSet);
+ boolean needsInvalidateForState = updateState();
+ return needsInvalidate || needsInvalidateForState;
+ }
+
+ public void setTintListCompat(ColorStateList colors) {
+ tintList = colors;
+ if (updateState()) {
+ invalidateSelf();
+ }
+ }
+
+ private boolean updateState() {
+ if (tintList != null) {
+ final int color = tintList.getColorForState(getState(), 0);
+ setColorFilter(color, PorterDuff.Mode.SRC_IN);
+ return true; // Needs invalidate
+ }
+ return false;
+ }
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/view/RichTextView.java b/main/src/com/google/android/setupdesign/view/RichTextView.java
new file mode 100644
index 0000000..c264947
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/view/RichTextView.java
@@ -0,0 +1,217 @@
+/*
+ * Copyright (C) 2016 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.google.android.setupdesign.view;
+
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.os.Build.VERSION;
+import android.os.Build.VERSION_CODES;
+import androidx.core.view.ViewCompat;
+import androidx.appcompat.widget.AppCompatTextView;
+import android.text.Annotation;
+import android.text.SpannableString;
+import android.text.Spanned;
+import android.text.method.MovementMethod;
+import android.text.style.ClickableSpan;
+import android.text.style.TextAppearanceSpan;
+import android.text.style.TypefaceSpan;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.view.MotionEvent;
+import com.google.android.setupdesign.span.LinkSpan;
+import com.google.android.setupdesign.span.LinkSpan.OnLinkClickListener;
+import com.google.android.setupdesign.span.SpanHelper;
+import com.google.android.setupdesign.util.LinkAccessibilityHelper;
+import com.google.android.setupdesign.view.TouchableMovementMethod.TouchableLinkMovementMethod;
+
+/**
+ * An extension of TextView that automatically replaces the annotation tags as specified in {@link
+ * SpanHelper#replaceSpan(android.text.Spannable, Object, Object)}
+ */
+public class RichTextView extends AppCompatTextView implements OnLinkClickListener {
+
+ /* static section */
+
+ private static final String TAG = "RichTextView";
+
+ private static final String ANNOTATION_LINK = "link";
+ private static final String ANNOTATION_TEXT_APPEARANCE = "textAppearance";
+
+ /**
+ * Replace &lt;annotation&gt; tags in strings to become their respective types. Currently 2 types
+ * are supported:
+ *
+ * <ol>
+ * <li>&lt;annotation link="foobar"&gt; will create a {@link
+ * com.google.android.setupdesign.span.LinkSpan} that broadcasts with the key "foobar"
+ * <li>&lt;annotation textAppearance="TextAppearance.FooBar"&gt; will create a {@link
+ * android.text.style.TextAppearanceSpan} with @style/TextAppearance.FooBar
+ * </ol>
+ */
+ public static CharSequence getRichText(Context context, CharSequence text) {
+ if (text instanceof Spanned) {
+ final SpannableString spannable = new SpannableString(text);
+ final Annotation[] spans = spannable.getSpans(0, spannable.length(), Annotation.class);
+ for (Annotation span : spans) {
+ final String key = span.getKey();
+ if (ANNOTATION_TEXT_APPEARANCE.equals(key)) {
+ String textAppearance = span.getValue();
+ final int style =
+ context
+ .getResources()
+ .getIdentifier(textAppearance, "style", context.getPackageName());
+ if (style == 0) {
+ Log.w(TAG, "Cannot find resource: " + style);
+ }
+ final TextAppearanceSpan textAppearanceSpan = new TextAppearanceSpan(context, style);
+ SpanHelper.replaceSpan(spannable, span, textAppearanceSpan);
+ } else if (ANNOTATION_LINK.equals(key)) {
+ LinkSpan link = new LinkSpan(span.getValue());
+ TypefaceSpan typefaceSpan = new TypefaceSpan("sans-serif-medium");
+ SpanHelper.replaceSpan(spannable, span, link, typefaceSpan);
+ }
+ }
+ return spannable;
+ }
+ return text;
+ }
+
+ /* non-static section */
+
+ private LinkAccessibilityHelper accessibilityHelper;
+ private OnLinkClickListener onLinkClickListener;
+
+ public RichTextView(Context context) {
+ super(context);
+ init();
+ }
+
+ public RichTextView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init();
+ }
+
+ private void init() {
+ accessibilityHelper = new LinkAccessibilityHelper(this);
+ ViewCompat.setAccessibilityDelegate(this, accessibilityHelper);
+ }
+
+ @Override
+ public void setText(CharSequence text, BufferType type) {
+ text = getRichText(getContext(), text);
+ // Set text first before doing anything else because setMovementMethod internally calls
+ // setText. This in turn ends up calling this method with mText as the first parameter
+ super.setText(text, type);
+ boolean hasLinks = hasLinks(text);
+
+ if (hasLinks) {
+ // When a TextView has a movement method, it will set the view to clickable. This makes
+ // View.onTouchEvent always return true and consumes the touch event, essentially
+ // nullifying any return values of MovementMethod.onTouchEvent.
+ // To still allow propagating touch events to the parent when this view doesn't have
+ // links, we only set the movement method here if the text contains links.
+ setMovementMethod(TouchableLinkMovementMethod.getInstance());
+ } else {
+ setMovementMethod(null);
+ }
+ // ExploreByTouchHelper automatically enables focus for RichTextView
+ // even though it may not have any links. Causes problems during talkback
+ // as individual TextViews consume touch events and thereby reducing the focus window
+ // shown by Talkback. Disable focus if there are no links
+ setFocusable(hasLinks);
+ // Do not "reveal" (i.e. scroll to) this view when this view is focused. Since this view is
+ // focusable in touch mode, we may be focused when the screen is first shown, and starting
+ // a screen halfway scrolled down is confusing to the user.
+ if (VERSION.SDK_INT >= VERSION_CODES.N_MR1) {
+ setRevealOnFocusHint(false);
+ // setRevealOnFocusHint is a new API added in SDK 25. For lower SDK versions, do not
+ // call setFocusableInTouchMode. We won't get touch effect on those earlier versions,
+ // but the link will still work, and will prevent the scroll view from starting halfway
+ // down the page.
+ setFocusableInTouchMode(hasLinks);
+ }
+ }
+
+ private boolean hasLinks(CharSequence text) {
+ if (text instanceof Spanned) {
+ final ClickableSpan[] spans =
+ ((Spanned) text).getSpans(0, text.length(), ClickableSpan.class);
+ return spans.length > 0;
+ }
+ return false;
+ }
+
+ @Override
+ @SuppressWarnings("ClickableViewAccessibility") // super.onTouchEvent is called
+ public boolean onTouchEvent(MotionEvent event) {
+ // Since View#onTouchEvent always return true if the view is clickable (which is the case
+ // when a TextView has a movement method), override the implementation to allow the movement
+ // method, if it implements TouchableMovementMethod, to say that the touch is not handled,
+ // allowing the event to bubble up to the parent view.
+ boolean superResult = super.onTouchEvent(event);
+ MovementMethod movementMethod = getMovementMethod();
+ if (movementMethod instanceof TouchableMovementMethod) {
+ TouchableMovementMethod touchableMovementMethod = (TouchableMovementMethod) movementMethod;
+ if (touchableMovementMethod.getLastTouchEvent() == event) {
+ return touchableMovementMethod.isLastTouchEventHandled();
+ }
+ }
+ return superResult;
+ }
+
+ @Override
+ protected boolean dispatchHoverEvent(MotionEvent event) {
+ if (accessibilityHelper != null && accessibilityHelper.dispatchHoverEvent(event)) {
+ return true;
+ }
+ return super.dispatchHoverEvent(event);
+ }
+
+ @Override
+ protected void drawableStateChanged() {
+ super.drawableStateChanged();
+
+ if (VERSION.SDK_INT >= VERSION_CODES.JELLY_BEAN_MR1) {
+ // b/26765507 causes drawableStart and drawableEnd to not get the right state on M. As a
+ // workaround, set the state on those drawables directly.
+ final int[] state = getDrawableState();
+ for (Drawable drawable : getCompoundDrawablesRelative()) {
+ if (drawable != null) {
+ if (drawable.setState(state)) {
+ invalidateDrawable(drawable);
+ }
+ }
+ }
+ }
+ }
+
+ public void setOnLinkClickListener(OnLinkClickListener listener) {
+ onLinkClickListener = listener;
+ }
+
+ public OnLinkClickListener getOnLinkClickListener() {
+ return onLinkClickListener;
+ }
+
+ @Override
+ public boolean onLinkClick(LinkSpan span) {
+ if (onLinkClickListener != null) {
+ return onLinkClickListener.onLinkClick(span);
+ }
+ return false;
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/view/StickyHeaderListView.java b/main/src/com/google/android/setupdesign/view/StickyHeaderListView.java
new file mode 100644
index 0000000..d5fef1a
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/view/StickyHeaderListView.java
@@ -0,0 +1,166 @@
+/*
+ * Copyright (C) 2015 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.google.android.setupdesign.view;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.Canvas;
+import android.graphics.RectF;
+import android.os.Build;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.WindowInsets;
+import android.view.accessibility.AccessibilityEvent;
+import android.widget.ListView;
+import com.google.android.setupdesign.R;
+
+/**
+ * This class provides sticky header functionality in a list view, to use with
+ * SetupWizardIllustration. To use this, add a header tagged with "sticky", or a header tagged with
+ * "stickyContainer" and one of its child tagged as "sticky". The sticky container will be drawn
+ * when the sticky element hits the top of the view.
+ *
+ * <p>There are a few things to note:
+ *
+ * <ol>
+ * <li>The two supported scenarios are StickyHeaderListView -> Header (stickyContainer) -> sticky,
+ * and StickyHeaderListView -> Header (sticky). The arrow (->) represents parent/child
+ * relationship and must be immediate child.
+ * <li>The view does not work well with padding. b/16190933
+ * <li>If fitsSystemWindows is true, then this will offset the sticking position by the height of
+ * the system decorations at the top of the screen.
+ * </ol>
+ *
+ * @see StickyHeaderScrollView
+ */
+public class StickyHeaderListView extends ListView {
+
+ private View sticky;
+ private View stickyContainer;
+ private int statusBarInset = 0;
+ private final RectF stickyRect = new RectF();
+
+ public StickyHeaderListView(Context context) {
+ super(context);
+ init(null, android.R.attr.listViewStyle);
+ }
+
+ public StickyHeaderListView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init(attrs, android.R.attr.listViewStyle);
+ }
+
+ public StickyHeaderListView(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ init(attrs, defStyleAttr);
+ }
+
+ private void init(AttributeSet attrs, int defStyleAttr) {
+ final TypedArray a =
+ getContext()
+ .obtainStyledAttributes(attrs, R.styleable.SuwStickyHeaderListView, defStyleAttr, 0);
+ int headerResId = a.getResourceId(R.styleable.SuwStickyHeaderListView_suwHeader, 0);
+ if (headerResId != 0) {
+ LayoutInflater inflater = LayoutInflater.from(getContext());
+ View header = inflater.inflate(headerResId, this, false);
+ addHeaderView(header, null, false);
+ }
+ a.recycle();
+ }
+
+ @Override
+ protected void onLayout(boolean changed, int l, int t, int r, int b) {
+ super.onLayout(changed, l, t, r, b);
+ if (sticky == null) {
+ updateStickyView();
+ }
+ }
+
+ public void updateStickyView() {
+ sticky = findViewWithTag("sticky");
+ stickyContainer = findViewWithTag("stickyContainer");
+ }
+
+ @Override
+ public boolean dispatchTouchEvent(MotionEvent ev) {
+ if (stickyRect.contains(ev.getX(), ev.getY())) {
+ ev.offsetLocation(-stickyRect.left, -stickyRect.top);
+ return stickyContainer.dispatchTouchEvent(ev);
+ } else {
+ return super.dispatchTouchEvent(ev);
+ }
+ }
+
+ @Override
+ public void draw(Canvas canvas) {
+ super.draw(canvas);
+ if (sticky != null) {
+ final int saveCount = canvas.save();
+ // The view to draw when sticking to the top
+ final View drawTarget = stickyContainer != null ? stickyContainer : sticky;
+ // The offset to draw the view at when sticky
+ final int drawOffset = stickyContainer != null ? sticky.getTop() : 0;
+ // Position of the draw target, relative to the outside of the scrollView
+ final int drawTop = drawTarget.getTop();
+ if (drawTop + drawOffset < statusBarInset || !drawTarget.isShown()) {
+ // ListView does not translate the canvas, so we can simply draw at the top
+ stickyRect.set(
+ 0,
+ -drawOffset + statusBarInset,
+ drawTarget.getWidth(),
+ drawTarget.getHeight() - drawOffset + statusBarInset);
+ canvas.translate(0, stickyRect.top);
+ canvas.clipRect(0, 0, drawTarget.getWidth(), drawTarget.getHeight());
+ drawTarget.draw(canvas);
+ } else {
+ stickyRect.setEmpty();
+ }
+ canvas.restoreToCount(saveCount);
+ }
+ }
+
+ @Override
+ @TargetApi(Build.VERSION_CODES.LOLLIPOP)
+ public WindowInsets onApplyWindowInsets(WindowInsets insets) {
+ if (getFitsSystemWindows()) {
+ statusBarInset = insets.getSystemWindowInsetTop();
+ insets.replaceSystemWindowInsets(
+ insets.getSystemWindowInsetLeft(),
+ 0, /* top */
+ insets.getSystemWindowInsetRight(),
+ insets.getSystemWindowInsetBottom());
+ }
+ return insets;
+ }
+
+ @Override
+ public void onInitializeAccessibilityEvent(AccessibilityEvent event) {
+ super.onInitializeAccessibilityEvent(event);
+
+ // Decoration-only headers should not count as an item for accessibility, adjust the
+ // accessibility event to account for that.
+ final int numberOfHeaders = sticky != null ? 1 : 0;
+ event.setItemCount(event.getItemCount() - numberOfHeaders);
+ event.setFromIndex(Math.max(event.getFromIndex() - numberOfHeaders, 0));
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
+ event.setToIndex(Math.max(event.getToIndex() - numberOfHeaders, 0));
+ }
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/view/StickyHeaderRecyclerView.java b/main/src/com/google/android/setupdesign/view/StickyHeaderRecyclerView.java
new file mode 100644
index 0000000..bd3d3e9
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/view/StickyHeaderRecyclerView.java
@@ -0,0 +1,145 @@
+/*
+ * Copyright (C) 2016 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.google.android.setupdesign.view;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.RectF;
+import android.os.Build;
+import android.util.AttributeSet;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.WindowInsets;
+
+/**
+ * This class provides sticky header functionality in a recycler view, to use with
+ * SetupWizardIllustration. To use this, add a header tagged with "sticky". The header will continue
+ * to be drawn when the sticky element hits the top of the view.
+ *
+ * <p>There are a few things to note:
+ *
+ * <ol>
+ * <li>The view does not work well with padding. b/16190933
+ * <li>If fitsSystemWindows is true, then this will offset the sticking position by the height of
+ * the system decorations at the top of the screen.
+ * </ol>
+ */
+public class StickyHeaderRecyclerView extends HeaderRecyclerView {
+
+ private View sticky;
+ private int statusBarInset = 0;
+ private final RectF stickyRect = new RectF();
+
+ public StickyHeaderRecyclerView(Context context) {
+ super(context);
+ }
+
+ public StickyHeaderRecyclerView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public StickyHeaderRecyclerView(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+
+ @Override
+ protected void onLayout(boolean changed, int l, int t, int r, int b) {
+ super.onLayout(changed, l, t, r, b);
+ if (sticky == null) {
+ updateStickyView();
+ }
+ if (sticky != null) {
+ final View headerView = getHeader();
+ if (headerView != null && headerView.getHeight() == 0) {
+ headerView.layout(0, -headerView.getMeasuredHeight(), headerView.getMeasuredWidth(), 0);
+ }
+ }
+ }
+
+ @Override
+ protected void onMeasure(int widthSpec, int heightSpec) {
+ super.onMeasure(widthSpec, heightSpec);
+ if (sticky != null) {
+ measureChild(getHeader(), widthSpec, heightSpec);
+ }
+ }
+
+ /**
+ * Call this method when the "sticky" view has changed, so this view can update its internal
+ * states as well.
+ */
+ public void updateStickyView() {
+ final View header = getHeader();
+ if (header != null) {
+ sticky = header.findViewWithTag("sticky");
+ }
+ }
+
+ @Override
+ public void draw(Canvas canvas) {
+ super.draw(canvas);
+ if (sticky != null) {
+ final View headerView = getHeader();
+ final int saveCount = canvas.save();
+ // The view to draw when sticking to the top
+ final View drawTarget = headerView != null ? headerView : sticky;
+ // The offset to draw the view at when sticky
+ final int drawOffset = headerView != null ? sticky.getTop() : 0;
+ // Position of the draw target, relative to the outside of the scrollView
+ final int drawTop = drawTarget.getTop();
+ if (drawTop + drawOffset < statusBarInset || !drawTarget.isShown()) {
+ // RecyclerView does not translate the canvas, so we can simply draw at the top
+ stickyRect.set(
+ 0,
+ -drawOffset + statusBarInset,
+ drawTarget.getWidth(),
+ drawTarget.getHeight() - drawOffset + statusBarInset);
+ canvas.translate(0, stickyRect.top);
+ canvas.clipRect(0, 0, drawTarget.getWidth(), drawTarget.getHeight());
+ drawTarget.draw(canvas);
+ } else {
+ stickyRect.setEmpty();
+ }
+ canvas.restoreToCount(saveCount);
+ }
+ }
+
+ @Override
+ @TargetApi(Build.VERSION_CODES.LOLLIPOP)
+ public WindowInsets onApplyWindowInsets(WindowInsets insets) {
+ if (getFitsSystemWindows()) {
+ statusBarInset = insets.getSystemWindowInsetTop();
+ insets.replaceSystemWindowInsets(
+ insets.getSystemWindowInsetLeft(),
+ 0, /* top */
+ insets.getSystemWindowInsetRight(),
+ insets.getSystemWindowInsetBottom());
+ }
+ return insets;
+ }
+
+ @Override
+ public boolean dispatchTouchEvent(MotionEvent ev) {
+ if (stickyRect.contains(ev.getX(), ev.getY())) {
+ ev.offsetLocation(-stickyRect.left, -stickyRect.top);
+ return getHeader().dispatchTouchEvent(ev);
+ } else {
+ return super.dispatchTouchEvent(ev);
+ }
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/view/StickyHeaderScrollView.java b/main/src/com/google/android/setupdesign/view/StickyHeaderScrollView.java
new file mode 100644
index 0000000..fa22f24
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/view/StickyHeaderScrollView.java
@@ -0,0 +1,118 @@
+/*
+ * Copyright (C) 2015 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.google.android.setupdesign.view;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.os.Build;
+import android.util.AttributeSet;
+import android.view.View;
+import android.view.WindowInsets;
+
+/**
+ * This class provides sticky header functionality in a scroll view, to use with
+ * SetupWizardIllustration. To use this, add a subview tagged with "sticky", or a subview tagged
+ * with "stickyContainer" and one of its child tagged as "sticky". The sticky container will be
+ * drawn when the sticky element hits the top of the view.
+ *
+ * <p>There are a few things to note:
+ *
+ * <ol>
+ * <li>The two supported scenarios are StickyHeaderScrollView -> subview (stickyContainer) ->
+ * sticky, and StickyHeaderScrollView -> container -> subview (sticky). The arrow (->)
+ * represents parent/child relationship and must be immediate child.
+ * <li>If fitsSystemWindows is true, then this will offset the sticking position by the height of
+ * the system decorations at the top of the screen.
+ * <li>For versions before Honeycomb, this will behave like a regular ScrollView.
+ * </ol>
+ *
+ * @see StickyHeaderListView
+ */
+public class StickyHeaderScrollView extends BottomScrollView {
+
+ private View sticky;
+ private View stickyContainer;
+ private int statusBarInset = 0;
+
+ public StickyHeaderScrollView(Context context) {
+ super(context);
+ }
+
+ public StickyHeaderScrollView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public StickyHeaderScrollView(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+
+ @Override
+ protected void onLayout(boolean changed, int l, int t, int r, int b) {
+ super.onLayout(changed, l, t, r, b);
+ if (sticky == null) {
+ updateStickyView();
+ }
+ updateStickyHeaderPosition();
+ }
+
+ public void updateStickyView() {
+ sticky = findViewWithTag("sticky");
+ stickyContainer = findViewWithTag("stickyContainer");
+ }
+
+ private void updateStickyHeaderPosition() {
+ // Note: for pre-Honeycomb the header will not be moved, so this ScrollView essentially
+ // behaves like a normal BottomScrollView.
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) {
+ if (sticky != null) {
+ // The view to draw when sticking to the top
+ final View drawTarget = stickyContainer != null ? stickyContainer : sticky;
+ // The offset to draw the view at when sticky
+ final int drawOffset = stickyContainer != null ? sticky.getTop() : 0;
+ // Position of the draw target, relative to the outside of the scrollView
+ final int drawTop = drawTarget.getTop() - getScrollY();
+ if (drawTop + drawOffset < statusBarInset || !drawTarget.isShown()) {
+ // ScrollView translates the whole canvas so we have to compensate for that
+ drawTarget.setTranslationY(getScrollY() - drawOffset);
+ } else {
+ drawTarget.setTranslationY(0);
+ }
+ }
+ }
+ }
+
+ @Override
+ protected void onScrollChanged(int l, int t, int oldl, int oldt) {
+ super.onScrollChanged(l, t, oldl, oldt);
+ updateStickyHeaderPosition();
+ }
+
+ @Override
+ @TargetApi(Build.VERSION_CODES.LOLLIPOP)
+ public WindowInsets onApplyWindowInsets(WindowInsets insets) {
+ if (getFitsSystemWindows()) {
+ statusBarInset = insets.getSystemWindowInsetTop();
+ insets =
+ insets.replaceSystemWindowInsets(
+ insets.getSystemWindowInsetLeft(),
+ 0, /* top */
+ insets.getSystemWindowInsetRight(),
+ insets.getSystemWindowInsetBottom());
+ }
+ return insets;
+ }
+}
diff --git a/main/src/com/google/android/setupdesign/view/TouchableMovementMethod.java b/main/src/com/google/android/setupdesign/view/TouchableMovementMethod.java
new file mode 100644
index 0000000..57cb60c
--- /dev/null
+++ b/main/src/com/google/android/setupdesign/view/TouchableMovementMethod.java
@@ -0,0 +1,79 @@
+/*
+ * 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.google.android.setupdesign.view;
+
+import android.text.Selection;
+import android.text.Spannable;
+import android.text.method.LinkMovementMethod;
+import android.text.method.MovementMethod;
+import android.view.MotionEvent;
+import android.widget.TextView;
+
+/**
+ * A movement method that tracks the last result of whether touch events are handled. This is used
+ * to patch the return value of {@link TextView#onTouchEvent} so that it consumes the touch events
+ * only when the movement method says the event is consumed.
+ */
+public interface TouchableMovementMethod {
+
+ /** @return The last touch event received in {@link MovementMethod#onTouchEvent} */
+ MotionEvent getLastTouchEvent();
+
+ /**
+ * @return The return value of the last {@link MovementMethod#onTouchEvent}, or whether the last
+ * touch event should be considered handled by the text view
+ */
+ boolean isLastTouchEventHandled();
+
+ /**
+ * An extension of LinkMovementMethod that tracks whether the event is handled when it is touched.
+ */
+ class TouchableLinkMovementMethod extends LinkMovementMethod implements TouchableMovementMethod {
+
+ public static TouchableLinkMovementMethod getInstance() {
+ return new TouchableLinkMovementMethod();
+ }
+
+ boolean lastEventResult = false;
+ MotionEvent lastEvent;
+
+ @Override
+ public boolean onTouchEvent(TextView widget, Spannable buffer, MotionEvent event) {
+ lastEvent = event;
+ boolean result = super.onTouchEvent(widget, buffer, event);
+ if (event.getAction() == MotionEvent.ACTION_DOWN) {
+ // Unfortunately, LinkMovementMethod extends ScrollMovementMethod, and it always
+ // consume the down event. So here we use the selection instead as a hint of whether
+ // the down event landed on a link.
+ lastEventResult = Selection.getSelectionStart(buffer) != -1;
+ } else {
+ lastEventResult = result;
+ }
+ return result;
+ }
+
+ @Override
+ public MotionEvent getLastTouchEvent() {
+ return lastEvent;
+ }
+
+ @Override
+ public boolean isLastTouchEventHandled() {
+ return lastEventResult;
+ }
+ }
+}