| // 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.reviewit.widget; |
| |
| import android.content.Context; |
| import android.graphics.drawable.ColorDrawable; |
| import android.os.Build; |
| import android.support.annotation.ColorInt; |
| import android.support.annotation.ColorRes; |
| import android.util.AttributeSet; |
| import android.view.Gravity; |
| import android.view.View; |
| import android.view.ViewGroup; |
| import android.view.ViewTreeObserver; |
| import android.view.Window; |
| import android.widget.LinearLayout; |
| import android.widget.RelativeLayout; |
| import android.widget.ScrollView; |
| import android.widget.TextView; |
| |
| import com.google.reviewit.R; |
| import com.google.reviewit.util.LayoutUtil; |
| import com.google.reviewit.util.WidgetUtil; |
| |
| import java.util.ArrayList; |
| import java.util.Iterator; |
| import java.util.List; |
| |
| import static com.google.reviewit.util.LayoutUtil.matchAndWrapLayout; |
| import static com.google.reviewit.util.LayoutUtil.matchLayout; |
| |
| /** |
| * Scroll view that displays a list of views that each have a heading. |
| * While scrolling the heading of the current view is fixed at the top. |
| */ |
| public class ScrollWithHeadingsView extends RelativeLayout { |
| private static final |
| @ColorRes |
| int[] BACKGROUND_COLORS = new int[] { |
| R.color.heading1, R.color.heading2, R.color.heading3, R.color.heading4, |
| R.color.heading5, R.color.heading6, R.color.heading7, R.color.heading8, |
| R.color.heading9, R.color.heading10, R.color.heading11, R.color.heading12}; |
| |
| private final ScrollView scroll; |
| private final LinearLayout scrollContent; |
| private final List<Content> contents = new ArrayList<>(); |
| private final ZoomHandler zoomHandler; |
| |
| private Window window; |
| private int nextBackgroundColor = 0; |
| private ViewTreeObserver.OnScrollChangedListener onScrollListener; |
| |
| public ScrollWithHeadingsView(Context context) { |
| this(context, null); |
| } |
| |
| public ScrollWithHeadingsView(Context context, AttributeSet attrs) { |
| super(context, attrs); |
| |
| scroll = new ScrollView(context); |
| scroll.setLayoutParams(matchLayout()); |
| addView(scroll); |
| |
| scrollContent = new LinearLayout(context); |
| scrollContent.setOrientation(LinearLayout.VERTICAL); |
| scrollContent.setLayoutParams(matchLayout()); |
| scroll.addView(scrollContent); |
| |
| zoomHandler = new ZoomHandler(this); |
| } |
| |
| public ZoomHandler getZoomHandler() { |
| return zoomHandler; |
| } |
| |
| public void setWindow(Window window) { |
| this.window = window; |
| } |
| |
| public void setContent(Iterator<Content> contentProvider) { |
| Content prevContent = null; |
| Content nextContent = contentProvider.hasNext() |
| ? contentProvider.next() |
| : null; |
| while (nextContent != null) { |
| final Content content = nextContent; |
| contents.add(content); |
| nextContent = contentProvider.hasNext() |
| ? contentProvider.next() |
| : null; |
| final boolean setWindowColor = prevContent == null; |
| post(new Runnable() { |
| @Override |
| public void run() { |
| addHeading(content); |
| if (setWindowColor) { |
| setWindowColor(content.getHeading()); |
| } |
| scrollContent.addView(content.getContent()); |
| } |
| }); |
| prevContent = content; |
| } |
| |
| onScrollListener = new ViewTreeObserver.OnScrollChangedListener() { |
| @Override |
| public void onScrollChanged() { |
| Iterator<Content> it = contents.iterator(); |
| Content prevContent = null; |
| Content nextContent = it.hasNext() ? it.next() : null; |
| while (nextContent != null) { |
| Content content = nextContent; |
| nextContent = it.hasNext() ? it.next() : null; |
| ScrollWithHeadingsView.this.onScrollChanged( |
| content, prevContent, nextContent); |
| prevContent = content; |
| } |
| } |
| }; |
| scroll.getViewTreeObserver().addOnScrollChangedListener(onScrollListener); |
| |
| LayoutUtil.addOneTimeOnGlobalLayoutListener(scroll, |
| new LayoutUtil.OneTimeOnGlobalLayoutListener() { |
| @Override |
| public void onFirstGlobalLayout() { |
| relayout(); |
| } |
| }); |
| } |
| |
| private void clear() { |
| for (Content content : contents) { |
| removeView(content.getHeading().getView()); |
| scrollContent.removeView(content.getContent()); |
| } |
| contents.clear(); |
| scroll.getViewTreeObserver() |
| .removeOnScrollChangedListener(onScrollListener); |
| onScrollListener = null; |
| } |
| |
| public void relayout() { |
| // hack to refresh the view, this ensures that the headers are positioned |
| // correctly |
| scroll.setScrollY(scroll.getScrollY() + 1); |
| scroll.setScrollY(scroll.getScrollY() - 1); |
| } |
| |
| private void addHeading(final Content content) { |
| final Heading heading = content.getHeading(); |
| heading.setBackgroundColor(getNextBackgroundColor()); |
| addView(heading.getView()); |
| |
| final View contentView = content.getContent(); |
| heading.addOnHeightListener(new LayoutUtil.OnHeightListener() { |
| @Override |
| public void onHeight(int height) { |
| contentView.setPadding(contentView.getPaddingLeft(), |
| contentView.getPaddingTop() + height, |
| contentView.getPaddingRight(), |
| contentView.getPaddingBottom()); |
| } |
| }); |
| |
| LayoutUtil.addOneTimeOnGlobalLayoutListener(contentView, |
| new LayoutUtil.OneTimeOnGlobalLayoutListener() { |
| @Override |
| public void onFirstGlobalLayout() { |
| heading.getView().setTranslationY( |
| contentView.getTop() - scroll.getScrollY()); |
| } |
| }); |
| } |
| |
| private void onScrollChanged( |
| Content content, final Content prevContent, final Content nextContent) { |
| final View contentView = content.getContent(); |
| final Heading heading = content.getHeading(); |
| final Heading prevHeading = |
| prevContent != null |
| ? prevContent.getHeading() |
| : null; |
| final Heading nextHeading = |
| nextContent != null |
| ? nextContent.getHeading() |
| : null; |
| if (scroll.getScrollY() >= contentView.getTop()) { |
| int scrollYRelativeToContentView = |
| scroll.getScrollY() - contentView.getTop(); |
| if (scrollYRelativeToContentView < heading.getSquishableHeight()) { |
| fixHeadingAtTop(heading, scrollYRelativeToContentView); |
| } else { |
| if (nextHeading != null |
| && nextHeading.getView().getTranslationY() > 0) { |
| // the position of nextHeading is only updated after this |
| // onScrollChanged callback, but we need its correct position |
| // now, hence update its position now |
| nextHeading.getView().setTranslationY( |
| nextContent.getContent().getTop() - scroll.getScrollY()); |
| } |
| if ((nextHeading != null |
| && nextHeading.getView().getTranslationY() <= 0) |
| || (prevHeading != null |
| && prevHeading.getView().getTranslationY() >= 0)) { |
| heading.squish(heading.getSquishableHeight()); |
| heading.getView().setTranslationY( |
| contentView.getTop() - scroll.getScrollY()); |
| } else { |
| fixHeadingAtTop(heading, heading.getSquishableHeight()); |
| } |
| } |
| } else { |
| heading.squish(0); |
| heading.getView().setTranslationY( |
| contentView.getTop() - scroll.getScrollY()); |
| |
| if (prevHeading != null) { |
| if (prevHeading.getView().getHeight() |
| > heading.getView().getTranslationY()) { |
| prevHeading.getView().setTranslationY( |
| heading.getView().getTranslationY() |
| - prevHeading.getView().getHeight()); |
| } |
| } |
| } |
| } |
| |
| @Override |
| protected void onDetachedFromWindow() { |
| super.onDetachedFromWindow(); |
| clear(); |
| } |
| |
| private void fixHeadingAtTop(Heading heading, int heightToSquish) { |
| heading.squish(heightToSquish); |
| heading.getView().setTranslationY(0); |
| setWindowColor(heading); |
| } |
| |
| private void setWindowColor(Heading heading) { |
| if (window != null |
| && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { |
| if (heading.getView().isAttachedToWindow()) { |
| window.setStatusBarColor(heading.getBackgroundColor()); |
| } |
| } |
| } |
| |
| private |
| @ColorRes |
| int getNextBackgroundColor() { |
| if (nextBackgroundColor >= BACKGROUND_COLORS.length) { |
| nextBackgroundColor = 0; |
| } |
| return BACKGROUND_COLORS[nextBackgroundColor++]; |
| } |
| |
| public void setNextBackgroundColor(int nextBackgroundColor) { |
| if (nextBackgroundColor < 0) { |
| return; |
| } |
| |
| while (nextBackgroundColor > BACKGROUND_COLORS.length) { |
| nextBackgroundColor -= BACKGROUND_COLORS.length; |
| } |
| |
| this.nextBackgroundColor = nextBackgroundColor; |
| } |
| |
| public static class Content { |
| private final Heading heading; |
| private final View content; |
| |
| public Content(Heading heading, View content) { |
| this.heading = heading; |
| this.content = content; |
| } |
| |
| public Heading getHeading() { |
| return heading; |
| } |
| |
| public View getContent() { |
| return content; |
| } |
| } |
| |
| public interface Heading { |
| /** |
| * Get the view that implements the heading. |
| */ |
| View getView(); |
| |
| /** |
| * The max height that the heading view can be squished. |
| */ |
| int getSquishableHeight(); |
| |
| /** |
| * Squishes the heading view. |
| */ |
| void squish(int heightToSquish); |
| |
| /** |
| * Set the background color of the heading view. |
| */ |
| void setBackgroundColor(@ColorRes int color); |
| |
| /** |
| * Get the background color of the heading view. |
| */ |
| @ColorInt |
| int getBackgroundColor(); |
| |
| /** |
| * Register callback that is invoked once when the height of the |
| * heading is known. |
| */ |
| void addOnHeightListener(LayoutUtil.OnHeightListener onHeightListener); |
| } |
| |
| public static class TextHeading implements Heading { |
| protected final Context context; |
| protected final WidgetUtil widgetUtil; |
| protected final View headingView; |
| protected final int headingPaddingMin; |
| protected final int headingPaddingMax; |
| |
| public TextHeading(Context context, String headingText) { |
| this.context = context; |
| this.widgetUtil = new WidgetUtil(context); |
| this.headingPaddingMin = widgetUtil.spToPx(12); |
| this.headingPaddingMax = widgetUtil.spToPx(50); |
| this.headingView = createHeadingView(headingText); |
| } |
| |
| private View createHeadingView(String headingText) { |
| MaxFontSizeTextView headingView = new MaxFontSizeTextView(context); |
| headingView.setLayoutParams(matchAndWrapLayout()); |
| headingView.setText(headingText); |
| headingView.setTextColor(widgetUtil.color(R.color.headingFont)); |
| headingView.setMinTextSize(widgetUtil.spToPx(10)); |
| headingView.setMaxTextSize(widgetUtil.spToPx(30)); |
| headingView.setGravity(Gravity.CENTER_HORIZONTAL); |
| headingView.setPadding(5, headingPaddingMax, 5, headingPaddingMax); |
| headingView.setIncludeFontPadding(false); |
| return headingView; |
| } |
| |
| @Override |
| public View getView() { |
| return headingView; |
| } |
| |
| @Override |
| public int getSquishableHeight() { |
| return 2 * (headingPaddingMax - headingPaddingMin); |
| } |
| |
| @Override |
| public void squish(int heightToSquish) { |
| if (heightToSquish > getSquishableHeight()) { |
| heightToSquish = getSquishableHeight(); |
| } |
| |
| int paddingTop = headingPaddingMax - heightToSquish / 2; |
| int paddingBottom = 2 * headingPaddingMax - heightToSquish - paddingTop; |
| headingView.setPadding(headingView.getPaddingLeft(), paddingTop, |
| headingView.getPaddingRight(), paddingBottom); |
| } |
| |
| @Override |
| public void setBackgroundColor(@ColorRes int color) { |
| headingView.setBackgroundColor(widgetUtil.color(color)); |
| } |
| |
| @Override |
| public |
| @ColorInt |
| int getBackgroundColor() { |
| return ((ColorDrawable) headingView.getBackground()).getColor(); |
| } |
| |
| @Override |
| public void addOnHeightListener( |
| LayoutUtil.OnHeightListener onHeightListener) { |
| LayoutUtil.addOnHeightListener(headingView, onHeightListener); |
| } |
| } |
| |
| public static class HeadingWithDetails extends TextHeading { |
| private final RelativeLayout layout; |
| private final TextView detailsTopView; |
| private final TextView detailsBottomView; |
| private int height; |
| |
| public HeadingWithDetails( |
| Context context, String headingText, String detailsTopText, |
| String detailsBottomText) { |
| this(context, headingText, new CenteredHeadingDetails(detailsTopText), |
| new CenteredHeadingDetails(detailsBottomText)); |
| } |
| |
| // TODO make top/bottom heading smaller if there are no top/bottom |
| // details |
| public HeadingWithDetails( |
| Context context, String headingText, HeadingDetails detailsTop, |
| HeadingDetails detailsBottom) { |
| super(context, headingText); |
| |
| LayoutUtil.addOneTimeOnGlobalLayoutListener(headingView, |
| new LayoutUtil.OneTimeOnGlobalLayoutListener() { |
| @Override |
| public void onFirstGlobalLayout() { |
| height = headingView.getHeight(); |
| layout.getLayoutParams().height = height; |
| } |
| }); |
| |
| layout = new RelativeLayout(context); |
| layout.setLayoutParams(matchAndWrapLayout()); |
| |
| detailsTopView = createDetailsView(detailsTop); |
| if (detailsTopView != null) { |
| layout.addView(detailsTopView); |
| } |
| |
| layout.addView(headingView); |
| |
| detailsBottomView = createDetailsView(detailsBottom); |
| if (detailsBottomView != null) { |
| layout.addView(gravityBottom(detailsBottomView)); |
| } |
| } |
| |
| private TextView createDetailsView(HeadingDetails details) { |
| if (details == null) { |
| return null; |
| } |
| |
| final MaxFontSizeTextView detailsView = |
| new MaxFontSizeTextView(context); |
| RelativeLayout.LayoutParams layoutParams = |
| new RelativeLayout.LayoutParams( |
| ViewGroup.LayoutParams.MATCH_PARENT, |
| ViewGroup.LayoutParams.WRAP_CONTENT); |
| layoutParams.setMargins(widgetUtil.dpToPx(5), 0, |
| widgetUtil.dpToPx(5), 0); |
| detailsView.setLayoutParams(layoutParams); |
| detailsView.setText(details.getText()); |
| detailsView.setTextColor(widgetUtil.color(R.color.headingFont)); |
| detailsView.setMinTextSize(widgetUtil.spToPx(5)); |
| detailsView.setMaxTextSize(widgetUtil.spToPx(15)); |
| detailsView.setGravity(details.getGravity()); |
| |
| LayoutUtil.addOnHeightListener(detailsView, |
| new LayoutUtil.OnHeightListener() { |
| @Override |
| public void onHeight(int height) { |
| // +8 to center it correctly, don't understand why it's not |
| // correct otherwise |
| int padding = (headingPaddingMax - height) / 2 + 8; |
| detailsView.setPadding(5, padding, 5, padding); |
| } |
| }); |
| |
| return detailsView; |
| } |
| |
| private View gravityBottom(View view) { |
| LinearLayout l = new LinearLayout(context); |
| l.setOrientation(LinearLayout.VERTICAL); |
| l.setLayoutParams(matchLayout()); |
| l.setGravity(Gravity.CENTER_HORIZONTAL); |
| View spacer = new View(context); |
| spacer.setLayoutParams(new LinearLayout.LayoutParams( |
| ViewGroup.LayoutParams.MATCH_PARENT, |
| ViewGroup.LayoutParams.WRAP_CONTENT, 1f)); |
| l.addView(spacer); |
| l.addView(view); |
| return l; |
| } |
| |
| @Override |
| public View getView() { |
| return layout; |
| } |
| |
| @Override |
| public void squish(int heightToSquish) { |
| super.squish(heightToSquish); |
| |
| float paddingSquish = heightToSquish / 2; |
| if (paddingSquish > headingPaddingMax / 3) { |
| if (detailsTopView != null) { |
| detailsTopView.setAlpha(0f); |
| } |
| if (detailsBottomView != null) { |
| detailsBottomView.setAlpha(0f); |
| } |
| } else if (paddingSquish > 0) { |
| float alpha = 1 - paddingSquish / (headingPaddingMax / 3); |
| if (detailsTopView != null) { |
| detailsTopView.setAlpha(alpha); |
| } |
| if (detailsBottomView != null) { |
| detailsBottomView.setAlpha(alpha); |
| } |
| } else { |
| if (detailsTopView != null) { |
| detailsTopView.setAlpha(1f); |
| } |
| if (detailsBottomView != null) { |
| detailsBottomView.setAlpha(1f); |
| } |
| } |
| |
| layout.getLayoutParams().height = height - heightToSquish; |
| |
| } |
| |
| public void setBackgroundColor(@ColorRes int color) { |
| layout.setBackgroundColor(widgetUtil.color(color)); |
| } |
| |
| @Override |
| public @ColorInt int getBackgroundColor() { |
| return ((ColorDrawable) layout.getBackground()).getColor(); |
| } |
| } |
| |
| public static abstract class HeadingDetails { |
| private final String text; |
| |
| public HeadingDetails(String text) { |
| this.text = text; |
| } |
| |
| public String getText() { |
| return text; |
| } |
| |
| public abstract int getGravity(); |
| } |
| |
| public static class CenteredHeadingDetails extends HeadingDetails { |
| public CenteredHeadingDetails(String text) { |
| super(text); |
| } |
| |
| public int getGravity() { |
| return Gravity.CENTER_HORIZONTAL; |
| } |
| } |
| |
| public static class RightAlignedHeadingDetails extends HeadingDetails { |
| public RightAlignedHeadingDetails(String text) { |
| super(text); |
| } |
| |
| public int getGravity() { |
| return Gravity.RIGHT; |
| } |
| } |
| } |