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.google.android.setupdesign.template; 18 19 import android.content.Context; 20 import android.os.Handler; 21 import android.os.Looper; 22 import androidx.annotation.NonNull; 23 import androidx.annotation.Nullable; 24 import androidx.annotation.StringRes; 25 import android.view.View; 26 import android.view.View.OnClickListener; 27 import android.widget.Button; 28 import com.google.android.setupcompat.internal.TemplateLayout; 29 import com.google.android.setupcompat.template.FooterButton; 30 import com.google.android.setupcompat.template.Mixin; 31 import com.google.android.setupdesign.view.NavigationBar; 32 33 /** 34 * A mixin to require the a scrollable container (BottomScrollView, RecyclerView or ListView) to be 35 * scrolled to bottom, making sure that the user sees all content above and below the fold. 36 */ 37 public class RequireScrollMixin implements Mixin { 38 39 /* static section */ 40 41 /** 42 * Listener for when the require-scroll state changes. Note that this only requires the user to 43 * scroll to the bottom once - if the user scrolled to the bottom and back-up, scrolling to bottom 44 * is not required again. 45 */ 46 public interface OnRequireScrollStateChangedListener { 47 48 /** 49 * Called when require-scroll state changed. 50 * 51 * @param scrollNeeded True if the user should be required to scroll to bottom. 52 */ onRequireScrollStateChanged(boolean scrollNeeded)53 void onRequireScrollStateChanged(boolean scrollNeeded); 54 } 55 56 /** 57 * A delegate to detect scrollability changes and to scroll the page. This provides a layer of 58 * abstraction for BottomScrollView, RecyclerView and ListView. The delegate should call {@link 59 * #notifyScrollabilityChange(boolean)} when the view scrollability is changed. 60 */ 61 interface ScrollHandlingDelegate { 62 63 /** Starts listening to scrollability changes at the target scrollable container. */ startListening()64 void startListening(); 65 66 /** Scroll the page content down by one page. */ pageScrollDown()67 void pageScrollDown(); 68 } 69 70 /* non-static section */ 71 72 private final Handler handler = new Handler(Looper.getMainLooper()); 73 74 private boolean requiringScrollToBottom = false; 75 76 // Whether the user have seen the more button yet. 77 private boolean everScrolledToBottom = false; 78 79 private ScrollHandlingDelegate delegate; 80 81 @Nullable private OnRequireScrollStateChangedListener listener; 82 83 /** @param templateLayout The template containing this mixin */ RequireScrollMixin(@onNull TemplateLayout templateLayout)84 public RequireScrollMixin(@NonNull TemplateLayout templateLayout) { 85 } 86 87 /** 88 * Sets the delegate to handle scrolling. The type of delegate should depend on whether the 89 * scrolling view is a BottomScrollView, RecyclerView or ListView. 90 */ setScrollHandlingDelegate(@onNull ScrollHandlingDelegate delegate)91 public void setScrollHandlingDelegate(@NonNull ScrollHandlingDelegate delegate) { 92 this.delegate = delegate; 93 } 94 95 /** 96 * Listen to require scroll state changes. When scroll is required, {@link 97 * OnRequireScrollStateChangedListener#onRequireScrollStateChanged(boolean)} is called with {@code 98 * true}, and vice versa. 99 */ setOnRequireScrollStateChangedListener( @ullable OnRequireScrollStateChangedListener listener)100 public void setOnRequireScrollStateChangedListener( 101 @Nullable OnRequireScrollStateChangedListener listener) { 102 this.listener = listener; 103 } 104 105 /** @return The scroll state listener previously set, or {@code null} if none is registered. */ getOnRequireScrollStateChangedListener()106 public OnRequireScrollStateChangedListener getOnRequireScrollStateChangedListener() { 107 return listener; 108 } 109 110 /** 111 * Creates an {@link OnClickListener} which if scrolling is required, will scroll the page down, 112 * and if scrolling is not required, delegates to the wrapped {@code listener}. Note that you 113 * should call {@link #requireScroll()} as well in order to start requiring scrolling. 114 * 115 * @param listener The listener to be invoked when scrolling is not needed and the user taps on 116 * the button. If {@code null}, the click listener will be a no-op when scroll is not 117 * required. 118 * @return A new {@link OnClickListener} which will scroll the page down or delegate to the given 119 * listener depending on the current require-scroll state. 120 */ createOnClickListener(@ullable final OnClickListener listener)121 public OnClickListener createOnClickListener(@Nullable final OnClickListener listener) { 122 return new OnClickListener() { 123 @Override 124 public void onClick(View view) { 125 if (requiringScrollToBottom) { 126 delegate.pageScrollDown(); 127 } else if (listener != null) { 128 listener.onClick(view); 129 } 130 } 131 }; 132 } 133 134 /** 135 * Coordinate with the given navigation bar to require scrolling on the page. The more button will 136 * be shown instead of the next button while scrolling is required. 137 */ 138 public void requireScrollWithNavigationBar(@NonNull final NavigationBar navigationBar) { 139 setOnRequireScrollStateChangedListener( 140 new OnRequireScrollStateChangedListener() { 141 @Override 142 public void onRequireScrollStateChanged(boolean scrollNeeded) { 143 navigationBar.getMoreButton().setVisibility(scrollNeeded ? View.VISIBLE : View.GONE); 144 navigationBar.getNextButton().setVisibility(scrollNeeded ? View.GONE : View.VISIBLE); 145 } 146 }); 147 navigationBar.getMoreButton().setOnClickListener(createOnClickListener(null)); 148 requireScroll(); 149 } 150 151 /** @see #requireScrollWithButton(Button, CharSequence, OnClickListener) */ 152 public void requireScrollWithButton( 153 @NonNull Button button, @StringRes int moreText, @Nullable OnClickListener onClickListener) { 154 requireScrollWithButton(button, button.getContext().getText(moreText), onClickListener); 155 } 156 157 /** 158 * Use the given {@code button} to require scrolling. When scrolling is required, the button label 159 * will change to {@code moreText}, and tapping the button will cause the page to scroll down. 160 * 161 * <p>Note: Calling {@link View#setOnClickListener} on the button after this method will remove 162 * its link to the require-scroll mechanism. If you need to do that, obtain the click listener 163 * from {@link #createOnClickListener(OnClickListener)}. 164 * 165 * <p>Note: The normal button label is taken from the button's text at the time of calling this 166 * method. Calling {@link android.widget.TextView#setText} after calling this method causes 167 * undefined behavior. 168 * 169 * @param button The button to use for require scroll. The button's "normal" label is taken from 170 * the text at the time of calling this method, and the click listener of it will be replaced. 171 * @param moreText The button label when scroll is required. 172 * @param onClickListener The listener for clicks when scrolling is not required. 173 */ 174 public void requireScrollWithButton( 175 @NonNull final Button button, 176 final CharSequence moreText, 177 @Nullable OnClickListener onClickListener) { 178 final CharSequence nextText = button.getText(); 179 button.setOnClickListener(createOnClickListener(onClickListener)); 180 setOnRequireScrollStateChangedListener( 181 new OnRequireScrollStateChangedListener() { 182 @Override 183 public void onRequireScrollStateChanged(boolean scrollNeeded) { 184 button.setText(scrollNeeded ? moreText : nextText); 185 } 186 }); 187 requireScroll(); 188 } 189 190 /** 191 * Use the given {@code button} to require scrolling. When scrolling is required, the button label 192 * will change to {@code moreText}, and tapping the button will cause the page to scroll down. 193 * 194 * <p>Note: Calling {@link View#setOnClickListener} on the button after this method will remove 195 * its link to the require-scroll mechanism. If you need to do that, obtain the click listener 196 * from {@link #createOnClickListener(OnClickListener)}. 197 * 198 * <p>Note: The normal button label is taken from the button's text at the time of calling this 199 * method. Calling {@link android.widget.TextView#setText} after calling this method causes 200 * undefined behavior. 201 * 202 * @param button The button to use for require scroll. The button's "normal" label is taken from 203 * the text at the time of calling this method, and the click listener of it will be replaced. 204 * @param moreText The button label when scroll is required. 205 * @param onClickListener The listener for clicks when scrolling is not required. 206 */ 207 public void requireScrollWithButton( 208 @NonNull Context context, 209 @NonNull FooterButton button, 210 @StringRes int moreText, 211 @Nullable OnClickListener onClickListener) { 212 requireScrollWithButton(button, context.getText(moreText), onClickListener); 213 } 214 215 /** 216 * Use the given {@code button} to require scrolling. When scrolling is required, the button label 217 * will change to {@code moreText}, and tapping the button will cause the page to scroll down. 218 * 219 * <p>Note: Calling {@link View#setOnClickListener} on the button after this method will remove 220 * its link to the require-scroll mechanism. If you need to do that, obtain the click listener 221 * from {@link #createOnClickListener(OnClickListener)}. 222 * 223 * <p>Note: The normal button label is taken from the button's text at the time of calling this 224 * method. Calling {@link android.widget.TextView#setText} after calling this method causes 225 * undefined behavior. 226 * 227 * @param button The button to use for require scroll. The button's "normal" label is taken from 228 * the text at the time of calling this method, and the click listener of it will be replaced. 229 * @param moreText The button label when scroll is required. 230 * @param onClickListener The listener for clicks when scrolling is not required. 231 */ 232 public void requireScrollWithButton( 233 @NonNull final FooterButton button, 234 final CharSequence moreText, 235 @Nullable OnClickListener onClickListener) { 236 final CharSequence nextText = button.getText(); 237 button.setOnClickListener(createOnClickListener(onClickListener)); 238 setOnRequireScrollStateChangedListener( 239 new OnRequireScrollStateChangedListener() { 240 @Override 241 public void onRequireScrollStateChanged(boolean scrollNeeded) { 242 button.setText(scrollNeeded ? moreText : nextText); 243 } 244 }); 245 requireScroll(); 246 } 247 248 /** 249 * @return True if scrolling is required. Note that this mixin only requires the user to scroll to 250 * the bottom once - if the user scrolled to the bottom and back-up, scrolling to bottom is 251 * not required again. 252 */ 253 public boolean isScrollingRequired() { 254 return requiringScrollToBottom; 255 } 256 257 /** 258 * Start requiring scrolling on the layout. After calling this method, this mixin will start 259 * listening to scroll events from the scrolling container, and call {@link 260 * OnRequireScrollStateChangedListener} when the scroll state changes. 261 */ 262 public void requireScroll() { 263 delegate.startListening(); 264 } 265 266 /** 267 * {@link ScrollHandlingDelegate} should call this method when the scrollability of the scrolling 268 * container changed, so this mixin can recompute whether scrolling should be required. 269 * 270 * @param canScrollDown True if the view can scroll down further. 271 */ 272 void notifyScrollabilityChange(boolean canScrollDown) { 273 if (canScrollDown == requiringScrollToBottom) { 274 // Already at the desired require-scroll state 275 return; 276 } 277 if (canScrollDown) { 278 if (!everScrolledToBottom) { 279 postScrollStateChange(true); 280 requiringScrollToBottom = true; 281 } 282 } else { 283 postScrollStateChange(false); 284 requiringScrollToBottom = false; 285 everScrolledToBottom = true; 286 } 287 } 288 289 private void postScrollStateChange(final boolean scrollNeeded) { 290 handler.post( 291 new Runnable() { 292 @Override 293 public void run() { 294 if (listener != null) { 295 listener.onRequireScrollStateChanged(scrollNeeded); 296 } 297 } 298 }); 299 } 300 } 301