1 /*
2  * Copyright (C) 2017 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.setupwizardlib.template;
18 
19 import android.os.Handler;
20 import android.os.Looper;
21 import androidx.annotation.NonNull;
22 import androidx.annotation.Nullable;
23 import androidx.annotation.StringRes;
24 import android.view.View;
25 import android.view.View.OnClickListener;
26 import android.widget.Button;
27 import com.android.setupwizardlib.TemplateLayout;
28 import com.android.setupwizardlib.view.NavigationBar;
29 
30 /**
31  * A mixin to require the a scrollable container (BottomScrollView, RecyclerView or ListView) to be
32  * scrolled to bottom, making sure that the user sees all content above and below the fold.
33  */
34 public class RequireScrollMixin implements Mixin {
35 
36   /* static section */
37 
38   /**
39    * Listener for when the require-scroll state changes. Note that this only requires the user to
40    * scroll to the bottom once - if the user scrolled to the bottom and back-up, scrolling to bottom
41    * is not required again.
42    */
43   public interface OnRequireScrollStateChangedListener {
44 
45     /**
46      * Called when require-scroll state changed.
47      *
48      * @param scrollNeeded True if the user should be required to scroll to bottom.
49      */
onRequireScrollStateChanged(boolean scrollNeeded)50     void onRequireScrollStateChanged(boolean scrollNeeded);
51   }
52 
53   /**
54    * A delegate to detect scrollability changes and to scroll the page. This provides a layer of
55    * abstraction for BottomScrollView, RecyclerView and ListView. The delegate should call {@link
56    * #notifyScrollabilityChange(boolean)} when the view scrollability is changed.
57    */
58   interface ScrollHandlingDelegate {
59 
60     /** Starts listening to scrollability changes at the target scrollable container. */
startListening()61     void startListening();
62 
63     /** Scroll the page content down by one page. */
pageScrollDown()64     void pageScrollDown();
65   }
66 
67   /* non-static section */
68 
69   private final Handler handler = new Handler(Looper.getMainLooper());
70 
71   private boolean requiringScrollToBottom = false;
72 
73   // Whether the user have seen the more button yet.
74   private boolean everScrolledToBottom = false;
75 
76   private ScrollHandlingDelegate delegate;
77 
78   @Nullable private OnRequireScrollStateChangedListener listener;
79 
80   /** @param templateLayout The template containing this mixin */
RequireScrollMixin(@onNull TemplateLayout templateLayout)81   public RequireScrollMixin(@NonNull TemplateLayout templateLayout) {
82   }
83 
84   /**
85    * Sets the delegate to handle scrolling. The type of delegate should depend on whether the
86    * scrolling view is a BottomScrollView, RecyclerView or ListView.
87    */
setScrollHandlingDelegate(@onNull ScrollHandlingDelegate delegate)88   public void setScrollHandlingDelegate(@NonNull ScrollHandlingDelegate delegate) {
89     this.delegate = delegate;
90   }
91 
92   /**
93    * Listen to require scroll state changes. When scroll is required, {@link
94    * OnRequireScrollStateChangedListener#onRequireScrollStateChanged(boolean)} is called with {@code
95    * true}, and vice versa.
96    */
setOnRequireScrollStateChangedListener( @ullable OnRequireScrollStateChangedListener listener)97   public void setOnRequireScrollStateChangedListener(
98       @Nullable OnRequireScrollStateChangedListener listener) {
99     this.listener = listener;
100   }
101 
102   /** @return The scroll state listener previously set, or {@code null} if none is registered. */
getOnRequireScrollStateChangedListener()103   public OnRequireScrollStateChangedListener getOnRequireScrollStateChangedListener() {
104     return listener;
105   }
106 
107   /**
108    * Creates an {@link OnClickListener} which if scrolling is required, will scroll the page down,
109    * and if scrolling is not required, delegates to the wrapped {@code listener}. Note that you
110    * should call {@link #requireScroll()} as well in order to start requiring scrolling.
111    *
112    * @param listener The listener to be invoked when scrolling is not needed and the user taps on
113    *     the button. If {@code null}, the click listener will be a no-op when scroll is not
114    *     required.
115    * @return A new {@link OnClickListener} which will scroll the page down or delegate to the given
116    *     listener depending on the current require-scroll state.
117    */
createOnClickListener(@ullable final OnClickListener listener)118   public OnClickListener createOnClickListener(@Nullable final OnClickListener listener) {
119     return new OnClickListener() {
120       @Override
121       public void onClick(View view) {
122         if (requiringScrollToBottom) {
123           delegate.pageScrollDown();
124         } else if (listener != null) {
125           listener.onClick(view);
126         }
127       }
128     };
129   }
130 
131   /**
132    * Coordinate with the given navigation bar to require scrolling on the page. The more button will
133    * be shown instead of the next button while scrolling is required.
134    */
135   public void requireScrollWithNavigationBar(@NonNull final NavigationBar navigationBar) {
136     setOnRequireScrollStateChangedListener(
137         new OnRequireScrollStateChangedListener() {
138           @Override
139           public void onRequireScrollStateChanged(boolean scrollNeeded) {
140             navigationBar.getMoreButton().setVisibility(scrollNeeded ? View.VISIBLE : View.GONE);
141             navigationBar.getNextButton().setVisibility(scrollNeeded ? View.GONE : View.VISIBLE);
142           }
143         });
144     navigationBar.getMoreButton().setOnClickListener(createOnClickListener(null));
145     requireScroll();
146   }
147 
148   /** @see #requireScrollWithButton(Button, CharSequence, OnClickListener) */
149   public void requireScrollWithButton(
150       @NonNull Button button, @StringRes int moreText, @Nullable OnClickListener onClickListener) {
151     requireScrollWithButton(button, button.getContext().getText(moreText), onClickListener);
152   }
153 
154   /**
155    * Use the given {@code button} to require scrolling. When scrolling is required, the button label
156    * will change to {@code moreText}, and tapping the button will cause the page to scroll down.
157    *
158    * <p>Note: Calling {@link View#setOnClickListener} on the button after this method will remove
159    * its link to the require-scroll mechanism. If you need to do that, obtain the click listener
160    * from {@link #createOnClickListener(OnClickListener)}.
161    *
162    * <p>Note: The normal button label is taken from the button's text at the time of calling this
163    * method. Calling {@link android.widget.TextView#setText} after calling this method causes
164    * undefined behavior.
165    *
166    * @param button The button to use for require scroll. The button's "normal" label is taken from
167    *     the text at the time of calling this method, and the click listener of it will be replaced.
168    * @param moreText The button label when scroll is required.
169    * @param onClickListener The listener for clicks when scrolling is not required.
170    */
171   public void requireScrollWithButton(
172       @NonNull final Button button,
173       final CharSequence moreText,
174       @Nullable OnClickListener onClickListener) {
175     final CharSequence nextText = button.getText();
176     button.setOnClickListener(createOnClickListener(onClickListener));
177     setOnRequireScrollStateChangedListener(
178         new OnRequireScrollStateChangedListener() {
179           @Override
180           public void onRequireScrollStateChanged(boolean scrollNeeded) {
181             button.setText(scrollNeeded ? moreText : nextText);
182           }
183         });
184     requireScroll();
185   }
186 
187   /**
188    * @return True if scrolling is required. Note that this mixin only requires the user to scroll to
189    *     the bottom once - if the user scrolled to the bottom and back-up, scrolling to bottom is
190    *     not required again.
191    */
192   public boolean isScrollingRequired() {
193     return requiringScrollToBottom;
194   }
195 
196   /**
197    * Start requiring scrolling on the layout. After calling this method, this mixin will start
198    * listening to scroll events from the scrolling container, and call {@link
199    * OnRequireScrollStateChangedListener} when the scroll state changes.
200    */
201   public void requireScroll() {
202     delegate.startListening();
203   }
204 
205   /**
206    * {@link ScrollHandlingDelegate} should call this method when the scrollability of the scrolling
207    * container changed, so this mixin can recompute whether scrolling should be required.
208    *
209    * @param canScrollDown True if the view can scroll down further.
210    */
211   void notifyScrollabilityChange(boolean canScrollDown) {
212     if (canScrollDown == requiringScrollToBottom) {
213       // Already at the desired require-scroll state
214       return;
215     }
216     if (canScrollDown) {
217       if (!everScrolledToBottom) {
218         postScrollStateChange(true);
219         requiringScrollToBottom = true;
220       }
221     } else {
222       postScrollStateChange(false);
223       requiringScrollToBottom = false;
224       everScrolledToBottom = true;
225     }
226   }
227 
228   private void postScrollStateChange(final boolean scrollNeeded) {
229     handler.post(
230         new Runnable() {
231           @Override
232           public void run() {
233             if (listener != null) {
234               listener.onRequireScrollStateChanged(scrollNeeded);
235             }
236           }
237         });
238   }
239 }
240