/* * Copyright © 2016-2017, Turing Technologies, an unincorporated organisation of Wynne Plaga * * 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.turingtechnologies.materialscrollbar; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.annotation.TargetApi; import android.content.Context; import android.content.res.TypedArray; import android.graphics.Color; import android.graphics.drawable.GradientDrawable; import android.os.Build; import android.support.annotation.ColorInt; import android.support.annotation.ColorRes; import android.support.v4.content.ContextCompat; import android.support.v4.view.ViewCompat; import android.support.v4.widget.SwipeRefreshLayout; import android.support.v7.widget.LinearLayoutManager; import android.support.v7.widget.RecyclerView; import android.util.AttributeSet; import android.util.TypedValue; import android.view.MotionEvent; import android.view.View; import android.view.ViewParent; import android.view.animation.Animation; import android.view.animation.TranslateAnimation; import android.widget.RelativeLayout; /* * Table Of Contents: * * I - Initial Setup * II - Abstraction for flavour differentiation * III - Customisation methods * IV - Misc Methods * * Outline for developers. * * The two flavours of the MaterialScrollBar are the DragScrollBar and the TouchScrollBar. They both * extend this class. Implementations which are unique to each flavour are implemented through * abstraction. The use of the T generic is used to maintain the identity of the subclass when * chaining settings (ie. So that DragScrollBar(...).setIndicator(...) will return dragScrollBar and * not MaterialScrollBar). * * The class can be instantiated only through XML. * * Scrolling logic is computed separably in ScrollingUtilities. A unique instance is made for each * instance of the bar. */ @SuppressWarnings({"unchecked", "unused"}) public abstract class MaterialScrollBar<T> extends RelativeLayout { //Component Views private View handleTrack; Handle handleThumb; Indicator indicator; //Characteristics int handleColour; int handleOffColour = Color.parseColor("#9c9c9c"); protected boolean hidden = true; private int textColour = ContextCompat.getColor(getContext(), android.R.color.white); boolean lightOnTouch; private TypedArray a; //XML attributes private Boolean rtl = false; boolean hiddenByUser = false; //Associated Objects RecyclerView recyclerView; private int seekId = 0; //ID of the associated RecyclerView ScrollingUtilities scrollUtils = new ScrollingUtilities(this); SwipeRefreshLayout swipeRefreshLayout; //Misc private OnLayoutChangeListener indicatorLayoutListener; private Runnable onSetup; //CHAPTER I - INITIAL SETUP //Programmatic constructor MaterialScrollBar(Context context, RecyclerView recyclerView, boolean lightOnTouch){ super(context); this.recyclerView = recyclerView; addView(setUpHandleTrack(context)); //Adds the handle track addView(setUpHandle(context, lightOnTouch)); //Adds the handle setRightToLeft(Utils.isRightToLeft(context)); //Detects and applies the Right-To-Left status of the app onSetup = new Runnable() { @Override public void run() {} }; generalSetup(); } //Style-less XML Constructor MaterialScrollBar(Context context, AttributeSet attributeSet){ this(context, attributeSet, 0); } //Styled XML Constructor MaterialScrollBar(Context context, AttributeSet attributeSet, int defStyle){ super(context, attributeSet, defStyle); setUpProps(context, attributeSet); //Discovers and applies some XML attributes addView(setUpHandleTrack(context)); //Adds the handle track addView(setUpHandle(context, a.getBoolean(R.styleable.MaterialScrollBar_msb_lightOnTouch, true))); //Adds the handle setRightToLeft(Utils.isRightToLeft(context)); //Detects and applies the Right-To-Left status of the app onSetup = new Runnable() { @Override public void run() { implementPreferences(); } }; implementFlavourPreferences(a); } //Unpacks XML attributes and ensures that no mandatory attributes are missing, then applies them. void setUpProps(Context context, AttributeSet attributes){ a = context.getTheme().obtainStyledAttributes( attributes, R.styleable.MaterialScrollBar, 0, 0); if(!a.hasValue(R.styleable.MaterialScrollBar_msb_lightOnTouch)){ throw new IllegalStateException( "You are missing the following required attributes from a scroll bar in your XML: lightOnTouch"); } if(!isInEditMode()){ seekId = a.getResourceId(R.styleable.MaterialScrollBar_msb_recyclerView, 0); //Discovers and saves the ID of the recyclerView } } //Sets up bar. View setUpHandleTrack(Context context){ handleTrack = new View(context); LayoutParams lp = new RelativeLayout.LayoutParams(Utils.getDP(12, this), LayoutParams.MATCH_PARENT); lp.addRule(ALIGN_PARENT_RIGHT); handleTrack.setLayoutParams(lp); handleTrack.setBackgroundColor(ContextCompat.getColor(context, android.R.color.darker_gray)); ViewCompat.setAlpha(handleTrack, 0.4F); return(handleTrack); } //Sets up handleThumb. Handle setUpHandle(Context context, Boolean lightOnTouch){ handleThumb = new Handle(context, getMode()); RelativeLayout.LayoutParams lp = new RelativeLayout.LayoutParams(Utils.getDP(12, this), Utils.getDP(72, this)); lp.addRule(ALIGN_PARENT_RIGHT); handleThumb.setLayoutParams(lp); this.lightOnTouch = lightOnTouch; int colourToSet; handleColour = fetchAccentColour(context); if(lightOnTouch){ colourToSet = Color.parseColor("#9c9c9c"); } else { colourToSet = handleColour; } handleThumb.setBackgroundColor(colourToSet); return handleThumb; } //Implements optional attributes. void implementPreferences(){ if(a.hasValue(R.styleable.MaterialScrollBar_msb_barColour)){ setBarColour(a.getColor(R.styleable.MaterialScrollBar_msb_barColour, 0)); } if(a.hasValue(R.styleable.MaterialScrollBar_msb_handleColour)){ setHandleColour(a.getColor(R.styleable.MaterialScrollBar_msb_handleColour, 0)); } if(a.hasValue(R.styleable.MaterialScrollBar_msb_handleOffColour)){ setHandleOffColour(a.getColor(R.styleable.MaterialScrollBar_msb_handleOffColour, 0)); } if(a.hasValue(R.styleable.MaterialScrollBar_msb_textColour)){ setTextColour(a.getColor(R.styleable.MaterialScrollBar_msb_textColour, 0)); } if(a.hasValue(R.styleable.MaterialScrollBar_msb_barThickness)){ setBarThickness(a.getDimensionPixelSize(R.styleable.MaterialScrollBar_msb_barThickness, 0)); } if(a.hasValue(R.styleable.MaterialScrollBar_msb_rightToLeft)){ setRightToLeft(a.getBoolean(R.styleable.MaterialScrollBar_msb_rightToLeft, false)); } } public T setRecyclerView(RecyclerView rv){ if(seekId != 0){ throw new IllegalStateException("There is already a recyclerView set by XML."); } else if (recyclerView != null){ throw new IllegalStateException("There is already a recyclerView set."); } recyclerView = rv; generalSetup(); return (T)this; } //Waits for all of the views to be attached to the window and then implements general setup. //Waiting must occur so that the relevant recyclerview can be found. @Override protected void onAttachedToWindow() { super.onAttachedToWindow(); if(seekId != 0){ recyclerView = (RecyclerView) getRootView().findViewById(seekId); generalSetup(); } } //General setup. private void generalSetup(){ recyclerView.setVerticalScrollBarEnabled(false); // disable any existing scrollbars recyclerView.addOnScrollListener(new scrollListener()); // lets us read when the recyclerView scrolls setTouchIntercept(); // catches touches on the bar identifySwipeRefreshParents(); checkCustomScrolling(); onSetup.run(); a.recycle(); //Hides the view TranslateAnimation anim = new TranslateAnimation( Animation.RELATIVE_TO_PARENT, 0.0f, Animation.RELATIVE_TO_SELF, rtl ? -getHideRatio() : getHideRatio(), Animation.RELATIVE_TO_PARENT, 0.0f, Animation.RELATIVE_TO_PARENT, 0.0f); anim.setDuration(0); anim.setFillAfter(true); hidden = true; startAnimation(anim); } //Identifies any SwipeRefreshLayout parent so that it can be disabled and enabled during scrolling. void identifySwipeRefreshParents(){ boolean cycle = true; ViewParent parent = getParent(); if(parent != null){ while(cycle){ if(parent instanceof SwipeRefreshLayout){ swipeRefreshLayout = (SwipeRefreshLayout)parent; cycle = false; } else { if(parent.getParent() == null){ cycle = false; } else { parent = parent.getParent(); } } } } } boolean sizeUnchecked = true; //Checks each time the bar is laid out. If there are few enough view that //they all fit on the screen then the bar is hidden. If a view is added which doesn't fit on //the screen then the bar is unhidden. @Override protected void onLayout(boolean changed, int l, int t, int r, int b) { super.onLayout(changed, l, t, r, b); if(recyclerView == null && !isInEditMode()){ throw new RuntimeException("You need to set a recyclerView for the scroll bar, either in the XML or using setRecyclerView()."); } if(sizeUnchecked && !isInEditMode()){ scrollUtils.getCurScrollState(); if(scrollUtils.getAvailableScrollHeight() <= 0){ handleTrack.setVisibility(GONE); handleThumb.setVisibility(GONE); } else { handleTrack.setVisibility(VISIBLE); handleThumb.setVisibility(VISIBLE); sizeUnchecked = false; } } } // Makes the bar render correctly for XML @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int desiredWidth = Utils.getDP(12, this); int desiredHeight = 100; int widthMode = MeasureSpec.getMode(widthMeasureSpec); int widthSize = MeasureSpec.getSize(widthMeasureSpec); int heightMode = MeasureSpec.getMode(heightMeasureSpec); int heightSize = MeasureSpec.getSize(heightMeasureSpec); int width; int height; //Measure Width if (widthMode == MeasureSpec.EXACTLY) { //Must be this size width = widthSize; } else if (widthMode == MeasureSpec.AT_MOST) { //Can't be bigger than... width = Math.min(desiredWidth, widthSize); } else { //Be whatever you want width = desiredWidth; } //Measure Height if (heightMode == MeasureSpec.EXACTLY) { //Must be this size height = heightSize; } else if (heightMode == MeasureSpec.AT_MOST) { //Can't be bigger than... height = Math.min(desiredHeight, heightSize); } else { height = desiredHeight; } super.onMeasure(widthMeasureSpec, heightMeasureSpec); setMeasuredDimension(width, height); } //CHAPTER II - ABSTRACTION FOR FLAVOUR DIFFERENTIATION abstract void setTouchIntercept(); abstract int getMode(); abstract float getHideRatio(); abstract void onScroll(); abstract boolean getHide(); abstract void implementFlavourPreferences(TypedArray a); abstract float getHandleOffset(); abstract float getIndicatorOffset(); //CHAPTER III - CUSTOMISATION METHODS private void checkCustomScrollingInterface(){ if((recyclerView.getAdapter() instanceof ICustomScroller)){ scrollUtils.customScroller = (ICustomScroller) recyclerView.getAdapter(); } } /** * The scrollBar should attempt to use dev provided scrolling logic and not default logic. * * The adapter must implement {@link ICustomScroller}. */ private void checkCustomScrolling(){ if (ViewCompat.isAttachedToWindow(this)) checkCustomScrollingInterface(); else addOnLayoutChangeListener(new OnLayoutChangeListener() { @Override public void onLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft, int oldTop, int oldRight, int oldBottom) { MaterialScrollBar.this.removeOnLayoutChangeListener(this); checkCustomScrollingInterface(); } }); } /** * Provides the ability to programmatically set the colour of the scrollbar handleThumb. * @param colour to set the handleThumb. */ public T setHandleColour(String colour){ handleColour = Color.parseColor(colour); setHandleColour(); return (T)this; } /** * Provides the ability to programmatically set the colour of the scrollbar handleThumb. * @param colour to set the handleThumb. */ public T setHandleColour(@ColorInt int colour){ handleColour = colour; setHandleColour(); return (T)this; } /** * Provides the ability to programmatically set the colour of the scrollbar handleThumb. * @param colourResId to set the handleThumb. */ public T setHandleColourRes(@ColorRes int colourResId){ handleColour = ContextCompat.getColor(getContext(), colourResId); setHandleColour(); return (T)this; } private void setHandleColour(){ if(indicator != null){ ((GradientDrawable)indicator.getBackground()).setColor(handleColour); } if(!lightOnTouch){ handleThumb.setBackgroundColor(handleColour); } } /** * Provides the ability to programmatically set the colour of the scrollbar handleThumb when unpressed. Only applies if lightOnTouch is true. * @param colour to set the handleThumb when unpressed. */ public T setHandleOffColour(String colour){ handleOffColour = Color.parseColor(colour); if(lightOnTouch){ handleThumb.setBackgroundColor(handleOffColour); } return (T)this; } /** * Provides the ability to programmatically set the colour of the scrollbar handleThumb when unpressed. Only applies if lightOnTouch is true. * @param colour to set the handleThumb when unpressed. */ public T setHandleOffColour(@ColorInt int colour){ handleOffColour = colour; if(lightOnTouch){ handleThumb.setBackgroundColor(handleOffColour); } return (T)this; } /** * Provides the ability to programmatically set the colour of the scrollbar handleThumb when unpressed. Only applies if lightOnTouch is true. * @param colourResId to set the handleThumb when unpressed. */ public T setHandleOffColourRes(@ColorRes int colourResId){ handleOffColour = ContextCompat.getColor(getContext(), colourResId); if(lightOnTouch){ handleThumb.setBackgroundColor(handleOffColour); } return (T)this; } /** * Provides the ability to programmatically set the colour of the scrollbar. * @param colour to set the bar. */ public T setBarColour(String colour){ handleTrack.setBackgroundColor(Color.parseColor(colour)); return (T)this; } /** * Provides the ability to programmatically set the colour of the scrollbar. * @param colour to set the bar. */ public T setBarColour(@ColorInt int colour){ handleTrack.setBackgroundColor(colour); return (T)this; } /** * Provides the ability to programmatically set the colour of the scrollbar. * @param colourResId to set the bar. */ public T setBarColourRes(@ColorRes int colourResId){ handleTrack.setBackgroundColor(ContextCompat.getColor(getContext(), colourResId)); return (T)this; } /** * Provides the ability to programmatically set the text colour of the indicator. Will do nothing if there is no section indicator. * @param colour to set the text of the indicator. */ public T setTextColour(@ColorInt int colour){ textColour = colour; if(indicator != null){ indicator.setTextColour(textColour); } return(T)this; } /** * Provides the ability to programmatically set the text colour of the indicator. Will do nothing if there is no section indicator. * @param colourResId to set the text of the indicator. */ public T setTextColourRes(@ColorRes int colourResId){ textColour = ContextCompat.getColor(getContext(), colourResId); if(indicator != null){ indicator.setTextColour(textColour); } return (T)this; } /** * Provides the ability to programmatically set the text colour of the indicator. Will do nothing if there is no section indicator. * @param colour to set the text of the indicator. */ public T setTextColour(String colour){ textColour = Color.parseColor(colour); if(indicator != null){ indicator.setTextColour(textColour); } return (T)this; } /** * Removes any indicator. */ public T removeIndicator(){ if(this.indicator != null){ this.indicator.removeAllViews(); } this.indicator = null; return (T)this; } /** * Adds an indicator which accompanies this scroll bar. * * @param addSpaceSide Should space be put between the indicator and the bar or should they touch? */ public T setIndicator(final Indicator indicator, final boolean addSpaceSide) { if(ViewCompat.isAttachedToWindow(this)){ setupIndicator(indicator, addSpaceSide); } else { removeOnLayoutChangeListener(indicatorLayoutListener); indicatorLayoutListener = new OnLayoutChangeListener() { @Override public void onLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft, int oldTop, int oldRight, int oldBottom){ setupIndicator(indicator, addSpaceSide); MaterialScrollBar.this.removeOnLayoutChangeListener(this); } }; addOnLayoutChangeListener(indicatorLayoutListener); } return (T)this; } /** * Shared code for the above method. */ private void setupIndicator(Indicator indicator, boolean addSpaceSide){ MaterialScrollBar.this.indicator = indicator; indicator.testAdapter(recyclerView.getAdapter()); indicator.setRTL(rtl); indicator.linkToScrollBar(MaterialScrollBar.this, addSpaceSide); indicator.setTextColour(textColour); } /** * Allows the developer to set a custom bar thickness. * @param thickness The desired bar thickness. */ public T setBarThickness(int thickness){ LayoutParams layoutParams = (LayoutParams) handleThumb.getLayoutParams(); layoutParams.width = thickness; handleThumb.setLayoutParams(layoutParams); layoutParams = (LayoutParams) handleTrack.getLayoutParams(); layoutParams.width = thickness; handleTrack.setLayoutParams(layoutParams); if(indicator != null){ indicator.setSizeCustom(thickness); } layoutParams = (LayoutParams) getLayoutParams(); layoutParams.width = thickness; setLayoutParams(layoutParams); return (T)this; } /** * Hide or unhide the scrollBar. */ public void setScrollBarHidden(boolean hidden){ hiddenByUser = hidden; if(hiddenByUser){ setVisibility(GONE); } else { setVisibility(VISIBLE); } } /** * Overrides the right-to-left settings for the scroll bar. */ public void setRightToLeft(boolean rtl){ this.rtl = rtl; handleThumb.setRightToLeft(rtl); if(indicator != null){ indicator.setRTL(rtl); indicator.setLayoutParams(indicator.refreshMargins((LayoutParams) indicator.getLayoutParams())); } } //CHAPTER IV - MISC METHODS //Fetch accent colour. static int fetchAccentColour(Context context) { TypedValue typedValue = new TypedValue(); TypedArray a = context.obtainStyledAttributes(typedValue.data, new int[] { R.attr.colorAccent }); int color = a.getColor(0, 0); a.recycle(); return color; } /** * Animates the bar out of view */ void fadeOut(){ if(!hidden){ TranslateAnimation anim = new TranslateAnimation( Animation.RELATIVE_TO_PARENT, 0.0f, Animation.RELATIVE_TO_SELF, rtl ? -getHideRatio() : getHideRatio(), Animation.RELATIVE_TO_PARENT, 0.0f, Animation.RELATIVE_TO_PARENT, 0.0f); anim.setDuration(150); anim.setFillAfter(true); hidden = true; startAnimation(anim); postDelayed(new Runnable() { @Override public void run() { handleThumb.expandHandle(); } }, anim.getDuration() / 3); } } /** * Animates the bar into view */ void fadeIn(){ if(hidden && getHide() && !hiddenByUser){ hidden = false; TranslateAnimation anim = new TranslateAnimation( Animation.RELATIVE_TO_SELF, rtl ? -getHideRatio() : getHideRatio(), Animation.RELATIVE_TO_SELF, 0.0f, Animation.RELATIVE_TO_PARENT, 0.0f, Animation.RELATIVE_TO_PARENT, 0.0f); anim.setDuration(150); anim.setFillAfter(true); startAnimation(anim); handleThumb.collapseHandle(); } } protected void onDown(MotionEvent event){ if (indicator != null && indicator.getVisibility() == INVISIBLE && recyclerView.getAdapter() != null) { indicator.setVisibility(VISIBLE); if(Build.VERSION.SDK_INT >= 12){ indicator.setAlpha(0F); indicator.animate().alpha(1F).setDuration(150).setListener(new AnimatorListenerAdapter() { @TargetApi(Build.VERSION_CODES.HONEYCOMB) @Override public void onAnimationEnd(Animator animation) { super.onAnimationEnd(animation); indicator.setAlpha(1F); } }); } } int top = handleThumb.getHeight() / 2; int bottom = recyclerView.getHeight() - Utils.getDP(72, recyclerView.getContext()); float boundedY = Math.max(top, Math.min(bottom, event.getY() - getHandleOffset())); scrollUtils.scrollToPositionAtProgress((boundedY - top) / (bottom - top)); scrollUtils.scrollHandleAndIndicator(); recyclerView.onScrolled(0, 0); if (lightOnTouch) { handleThumb.setBackgroundColor(handleColour); } } protected void onUp(){ if (indicator != null && indicator.getVisibility() == VISIBLE) { if (Build.VERSION.SDK_INT <= 12) { indicator.clearAnimation(); } if(Build.VERSION.SDK_INT >= 12){ indicator.animate().alpha(0F).setDuration(150).setListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { super.onAnimationEnd(animation); indicator.setVisibility(INVISIBLE); } }); } else { indicator.setVisibility(INVISIBLE); } } if (lightOnTouch) { handleThumb.setBackgroundColor(handleOffColour); } } class scrollListener extends RecyclerView.OnScrollListener { @Override public void onScrolled(RecyclerView recyclerView, int dx, int dy) { super.onScrolled(recyclerView, dx, dy); scrollUtils.scrollHandleAndIndicator(); if(dy != 0){ onScroll(); } //Disables any swipeRefreshLayout parent if the recyclerview is not at the top and enables it if it is. if(swipeRefreshLayout != null && !swipeRefreshLayout.isRefreshing()){ if(((LinearLayoutManager)recyclerView.getLayoutManager()).findFirstCompletelyVisibleItemPosition() == 0){ swipeRefreshLayout.setEnabled(true); } else { swipeRefreshLayout.setEnabled(false); } } } } }