diff options
author | Setup Wizard Team <android-setup-team-eng@google.com> | 2018-11-20 23:25:32 +0800 |
---|---|---|
committer | cnchen <cnchen@google.com> | 2018-12-03 20:08:19 +0800 |
commit | a4cf3a6e436b44e394f21f5dc9c460acfd2f2f90 (patch) | |
tree | dbee0293f1e18e8b68f2ae08b6480e3dbf3dd054 /main/src | |
parent | b2ae103b9ffaf2e36d9237169f412c08ce7be7ce (diff) | |
download | setupdesign-a4cf3a6e436b44e394f21f5dc9c460acfd2f2f90.tar.gz |
Import updated Android Setupdesign Library 222242242
Test: mm
PiperOrigin-RevId: 222242242
Change-Id: I8dc8b996a94876a76475f3f035c3e14c1a620f74
Diffstat (limited to 'main/src')
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 + * <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"> + * + * <!-- Content here --> + * + * </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 + * <style android:name="MyActivityTheme"> + * <item android:name="preferenceTheme">@style/MyPreferenceTheme</item> + * </style> + * + * <style android:name="MyPreferenceTheme"> + * <item android:name="preferenceFragmentStyle">@style/MyPreferenceFragmentStyle</item> + * </style> + * + * <style android:name="MyPreferenceFragmentStyle"> + * <item android:name="android:layout">@layout/my_preference_layout</item> + * </style> + * }</pre> + * + * where {@code my_preference_layout} is a layout that contains {@link + * com.google.android.setupdesign.GlifPreferenceLayout}. + * + * <p>Example: + * + * <pre>{@code + * <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 + * <style android:name="MyActivityTheme"> + * <item android:name="preferenceTheme">@style/MyPreferenceTheme</item> + * </style> + * + * <style android:name="MyPreferenceTheme"> + * <item android:name="preferenceFragmentStyle">@style/MyPreferenceFragmentStyle</item> + * </style> + * + * <style android:name="MyPreferenceFragmentStyle"> + * <item android:name="android:layout">@layout/my_preference_layout</item> + * </style> + * }</pre> + * + * where {@code my_preference_layout} is a layout that contains {@link + * com.google.android.setupdesign.SetupWizardPreferenceLayout}. + * + * <p>Example: + * + * <pre>{@code + * <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 + * <ButtonBarItem> + * + * <ButtonItem + * android:id="@+id/skip_button" + * android:text="@string/skip_button_label /> + * + * <ButtonItem + * android:id="@+id/next_button" + * android:text="@string/next_button_label + * android:theme="@style/SuwButtonItem.Colored" /> + * + * </ButtonBarItem> + * }</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> 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 <annotation> tags in strings to become their respective types. Currently 2 types + * are supported: + * + * <ol> + * <li><annotation link="foobar"> will create a {@link + * com.google.android.setupdesign.span.LinkSpan} that broadcasts with the key "foobar" + * <li><annotation textAppearance="TextAppearance.FooBar"> 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; + } + } +} |