aboutsummaryrefslogtreecommitdiff
path: root/core-ui/src/main/kotlin
diff options
context:
space:
mode:
Diffstat (limited to 'core-ui/src/main/kotlin')
-rw-r--r--core-ui/src/main/kotlin/ca/allanwang/kau/ui/views/BoundedCardView.kt47
-rw-r--r--core-ui/src/main/kotlin/ca/allanwang/kau/ui/views/CutoutView.kt183
-rw-r--r--core-ui/src/main/kotlin/ca/allanwang/kau/ui/widgets/ElasticDragDismissFrameLayout.kt237
-rw-r--r--core-ui/src/main/kotlin/ca/allanwang/kau/ui/widgets/InkPageIndicator.java859
-rw-r--r--core-ui/src/main/kotlin/ca/allanwang/kau/ui/widgets/TextSlider.kt125
5 files changed, 1451 insertions, 0 deletions
diff --git a/core-ui/src/main/kotlin/ca/allanwang/kau/ui/views/BoundedCardView.kt b/core-ui/src/main/kotlin/ca/allanwang/kau/ui/views/BoundedCardView.kt
new file mode 100644
index 0000000..554f71f
--- /dev/null
+++ b/core-ui/src/main/kotlin/ca/allanwang/kau/ui/views/BoundedCardView.kt
@@ -0,0 +1,47 @@
+package ca.allanwang.kau.ui.views
+
+import android.content.Context
+import android.support.v7.widget.CardView
+import android.util.AttributeSet
+import ca.allanwang.kau.R
+
+
+/**
+ * Created by Allan Wang on 2017-06-26.
+ *
+ * CardView with a limited height
+ * This view should be used with wrap_content as its height
+ * Defaults to at most the parent's visible height
+ */
+class BoundedCardView @JvmOverloads constructor(
+ context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
+) : CardView(context, attrs, defStyleAttr) {
+
+ /**
+ * Maximum height possible, defined in dp (will be converted to px)
+ * Defaults to parent's visible height
+ */
+ var maxHeight: Int = -1
+ /**
+ * Percentage of resulting max height to fill
+ * Negative value = fill all of maxHeight
+ */
+ var maxHeightPercent: Float = -1.0f
+
+ init {
+ if (attrs != null) {
+ val styledAttrs = context.obtainStyledAttributes(attrs, R.styleable.BoundedCardView)
+ maxHeight = styledAttrs.getDimensionPixelSize(R.styleable.BoundedCardView_maxHeight, -1)
+ maxHeightPercent = styledAttrs.getFloat(R.styleable.BoundedCardView_maxHeightPercent, -1.0f)
+ styledAttrs.recycle()
+ }
+ }
+
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
+ var maxMeasureHeight = if (maxHeight > 0) maxHeight else parentVisibleHeight
+ if (maxHeightPercent > 0f) maxMeasureHeight = (maxMeasureHeight * maxHeightPercent).toInt()
+ val trueHeightMeasureSpec = MeasureSpec.makeMeasureSpec(maxMeasureHeight, MeasureSpec.AT_MOST)
+ super.onMeasure(widthMeasureSpec, trueHeightMeasureSpec)
+ }
+
+} \ No newline at end of file
diff --git a/core-ui/src/main/kotlin/ca/allanwang/kau/ui/views/CutoutView.kt b/core-ui/src/main/kotlin/ca/allanwang/kau/ui/views/CutoutView.kt
new file mode 100644
index 0000000..abd96ed
--- /dev/null
+++ b/core-ui/src/main/kotlin/ca/allanwang/kau/ui/views/CutoutView.kt
@@ -0,0 +1,183 @@
+/*
+ * Copyright 2015 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 ca.allanwang.kau.ui.views
+
+import android.content.Context
+import android.graphics.*
+import android.graphics.drawable.Drawable
+import android.text.TextPaint
+import android.util.AttributeSet
+import android.util.DisplayMetrics
+import android.util.TypedValue
+import android.view.View
+import ca.allanwang.kau.R
+import ca.allanwang.kau.utils.dimenPixelSize
+import ca.allanwang.kau.utils.getFont
+import ca.allanwang.kau.utils.parentVisibleHeight
+import ca.allanwang.kau.utils.toBitmap
+
+/**
+ * A view which punches out some text from an opaque color block, allowing you to see through it.
+ */
+class CutoutView @JvmOverloads constructor(
+ context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
+) : View(context, attrs, defStyleAttr) {
+
+ companion object {
+ const val PHI = 1.6182f
+ const val TYPE_TEXT = 100
+ const val TYPE_DRAWABLE = 101
+ }
+
+ private val paint: TextPaint = TextPaint(Paint.ANTI_ALIAS_FLAG)
+ private var bitmapScaling: Float = 1f
+ private var cutout: Bitmap? = null
+ var foregroundColor = Color.MAGENTA
+ var text: String? = "Text"
+ set(value) {
+ field = value
+ if (value != null) cutoutType = TYPE_TEXT
+ else if (drawable != null) cutoutType = TYPE_DRAWABLE
+ }
+ var cutoutType: Int = TYPE_TEXT
+ private var textSize: Float = 0f
+ private var cutoutY: Float = 0f
+ private var cutoutX: Float = 0f
+ var drawable: Drawable? = null
+ set(value) {
+ field = value
+ if (value != null) cutoutType = TYPE_DRAWABLE
+ else if (text != null) cutoutType = TYPE_TEXT
+ }
+ private var heightPercentage: Float = 0f
+ private var minHeight: Float = 0f
+ private val maxTextSize: Float
+
+ init {
+ if (attrs != null) {
+ val a = context.obtainStyledAttributes(attrs, R.styleable.CutoutView, 0, 0)
+ if (a.hasValue(R.styleable.CutoutView_font))
+ paint.typeface = context.getFont(a.getString(R.styleable.CutoutView_font))
+ foregroundColor = a.getColor(R.styleable.CutoutView_foregroundColor, foregroundColor)
+ text = a.getString(R.styleable.CutoutView_android_text) ?: text
+ minHeight = a.getDimension(R.styleable.CutoutView_android_minHeight, minHeight)
+ heightPercentage = a.getFloat(R.styleable.CutoutView_heightPercentageToScreen, heightPercentage)
+ a.recycle()
+ }
+ maxTextSize = context.dimenPixelSize(R.dimen.kau_display_4_text_size).toFloat()
+ }
+
+ override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
+ super.onSizeChanged(w, h, oldw, oldh)
+ calculatePosition()
+ createBitmap()
+ }
+
+ private fun calculatePosition() {
+ when (cutoutType) {
+ TYPE_TEXT -> calculateTextPosition()
+ TYPE_DRAWABLE -> calculateImagePosition()
+ }
+ }
+
+ private fun calculateTextPosition() {
+ val targetWidth = width / PHI
+ textSize = getSingleLineTextSize(text!!, paint, targetWidth, 0f, maxTextSize,
+ 0.5f, resources.displayMetrics)
+ paint.textSize = textSize
+
+ // measuring text is fun :] see: https://chris.banes.me/2014/03/27/measuring-text/
+ cutoutX = (width - paint.measureText(text)) / 2
+ val textBounds = Rect()
+ paint.getTextBounds(text, 0, text!!.length, textBounds)
+ val textHeight = textBounds.height().toFloat()
+ cutoutY = (height + textHeight) / 2
+ }
+
+ private fun calculateImagePosition() {
+ if (drawable!!.intrinsicHeight <= 0 || drawable!!.intrinsicWidth <= 0) throw IllegalArgumentException("Drawable's intrinsic size cannot be less than 0")
+ val targetWidth = width / PHI
+ val targetHeight = height / PHI
+ bitmapScaling = Math.min(targetHeight / drawable!!.intrinsicHeight, targetWidth / drawable!!.intrinsicWidth)
+ cutoutX = (width - drawable!!.intrinsicWidth * bitmapScaling) / 2
+ cutoutY = (height - drawable!!.intrinsicHeight * bitmapScaling) / 2
+ }
+
+ /**
+ * If height percent is specified, ensure it is met
+ */
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
+ val minHeight = Math.max(minHeight, heightPercentage * parentVisibleHeight)
+ val trueHeightMeasureSpec = if (minHeight > 0)
+ MeasureSpec.makeMeasureSpec(Math.max(minHeight.toInt(), measuredHeight), MeasureSpec.EXACTLY)
+ else heightMeasureSpec
+ super.onMeasure(widthMeasureSpec, trueHeightMeasureSpec)
+ }
+
+ /**
+ * Recursive binary search to find the best size for the text.
+
+ * Adapted from https://github.com/grantland/android-autofittextview
+ */
+ fun getSingleLineTextSize(text: String,
+ paint: TextPaint,
+ targetWidth: Float,
+ low: Float,
+ high: Float,
+ precision: Float,
+ metrics: DisplayMetrics): Float {
+ val mid = (low + high) / 2.0f
+
+ paint.textSize = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_PX, mid, metrics)
+ val maxLineWidth = paint.measureText(text)
+
+ return if (high - low < precision) low
+ else if (maxLineWidth > targetWidth) getSingleLineTextSize(text, paint, targetWidth, low, mid, precision, metrics)
+ else if (maxLineWidth < targetWidth) getSingleLineTextSize(text, paint, targetWidth, mid, high, precision, metrics)
+ else mid
+ }
+
+ private fun createBitmap() {
+ if (!(cutout?.isRecycled ?: true))
+ cutout?.recycle()
+ if (width == 0 || height == 0) return
+ cutout = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
+ cutout!!.setHasAlpha(true)
+ val cutoutCanvas = Canvas(cutout!!)
+ cutoutCanvas.drawColor(foregroundColor)
+ paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR)
+
+ when (cutoutType) {
+ TYPE_TEXT -> {
+ // this is the magic – Clear mode punches out the bitmap
+ paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR)
+ cutoutCanvas.drawText(text, cutoutX, cutoutY, paint)
+ }
+ TYPE_DRAWABLE -> {
+ cutoutCanvas.drawBitmap(drawable!!.toBitmap(bitmapScaling, Bitmap.Config.ALPHA_8), cutoutX, cutoutY, paint)
+ }
+
+ }
+ }
+
+ override fun onDraw(canvas: Canvas) {
+ canvas.drawBitmap(cutout!!, 0f, 0f, null)
+ }
+
+ override fun hasOverlappingRendering(): Boolean = true
+
+}
diff --git a/core-ui/src/main/kotlin/ca/allanwang/kau/ui/widgets/ElasticDragDismissFrameLayout.kt b/core-ui/src/main/kotlin/ca/allanwang/kau/ui/widgets/ElasticDragDismissFrameLayout.kt
new file mode 100644
index 0000000..452fd56
--- /dev/null
+++ b/core-ui/src/main/kotlin/ca/allanwang/kau/ui/widgets/ElasticDragDismissFrameLayout.kt
@@ -0,0 +1,237 @@
+/*
+ * Copyright 2015 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 ca.allanwang.kau.ui.widgets
+
+import android.app.Activity
+import android.content.Context
+import android.graphics.Color
+import android.util.AttributeSet
+import android.view.View
+import android.widget.FrameLayout
+import ca.allanwang.kau.R
+import ca.allanwang.kau.utils.*
+
+/**
+ * A [FrameLayout] which responds to nested scrolls to create drag-dismissable layouts.
+ * Applies an elasticity factor to reduce movement as you approach the given dismiss distance.
+ * Optionally also scales down content during drag.
+ */
+class ElasticDragDismissFrameLayout @JvmOverloads constructor(
+ context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0, defStyleRes: Int = 0
+) : FrameLayout(context, attrs, defStyleAttr, defStyleRes) {
+
+ // configurable attribs
+ var dragDismissDistance = context.dimen(R.dimen.kau_drag_dismiss_distance).dpToPx
+ var dragDismissFraction = -1f
+ var dragDismissScale = 1f
+ set(value) {
+ field = value
+ shouldScale = value != 1f
+ }
+ private var shouldScale = false
+ var dragElacticity = 0.8f
+
+ // state
+ private var totalDrag: Float = 0f
+ private var draggingDown = false
+ private var draggingUp = false
+
+ private var callbacks: MutableList<ElasticDragDismissCallback> = mutableListOf()
+
+ init {
+ if (attrs != null) {
+ val a = getContext().obtainStyledAttributes(attrs, R.styleable.ElasticDragDismissFrameLayout, 0, 0)
+ dragDismissDistance = a.getDimensionPixelSize(R.styleable.ElasticDragDismissFrameLayout_dragDismissDistance, Int.MAX_VALUE).toFloat()
+ dragDismissFraction = a.getFloat(R.styleable.ElasticDragDismissFrameLayout_dragDismissFraction, dragDismissFraction)
+ dragDismissScale = a.getFloat(R.styleable.ElasticDragDismissFrameLayout_dragDismissScale, dragDismissScale)
+ dragElacticity = a.getFloat(R.styleable.ElasticDragDismissFrameLayout_dragElasticity, dragElacticity)
+ a.recycle()
+ }
+ }
+
+ abstract class ElasticDragDismissCallback {
+
+ /**
+ * Called for each drag event.
+
+ * @param elasticOffset Indicating the drag offset with elasticity applied i.e. may
+ * * exceed 1.
+ * *
+ * @param elasticOffsetPixels The elastically scaled drag distance in pixels.
+ * *
+ * @param rawOffset Value from [0, 1] indicating the raw drag offset i.e.
+ * * without elasticity applied. A value of 1 indicates that the
+ * * dismiss distance has been reached.
+ * *
+ * @param rawOffsetPixels The raw distance the user has dragged
+ */
+ internal open fun onDrag(elasticOffset: Float, elasticOffsetPixels: Float,
+ rawOffset: Float, rawOffsetPixels: Float) {
+ }
+
+ /**
+ * Called when dragging is released and has exceeded the threshold dismiss distance.
+ */
+ internal open fun onDragDismissed() {}
+
+ }
+
+ override fun onStartNestedScroll(child: View, target: View, nestedScrollAxes: Int): Boolean {
+ return nestedScrollAxes and View.SCROLL_AXIS_VERTICAL != 0
+ }
+
+ override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray) {
+ // if we're in a drag gesture and the user reverses up the we should take those events
+ if (draggingDown && dy > 0 || draggingUp && dy < 0) {
+ dragScale(dy)
+ consumed[1] = dy
+ }
+ }
+
+ override fun onNestedScroll(target: View, dxConsumed: Int, dyConsumed: Int,
+ dxUnconsumed: Int, dyUnconsumed: Int) {
+ dragScale(dyUnconsumed)
+ }
+
+ override fun onStopNestedScroll(child: View) {
+ if (Math.abs(totalDrag) >= dragDismissDistance) {
+ dispatchDismissCallback()
+ } else { // settle back to natural position
+ animate()
+ .translationY(0f)
+ .scaleX(1f)
+ .scaleY(1f)
+ .setDuration(200L)
+ .setInterpolator(AnimHolder.fastOutSlowInInterpolator(context))
+ .setListener(null)
+ .start()
+ totalDrag = 0f
+ draggingUp = false
+ draggingDown = draggingUp
+ dispatchDragCallback(0f, 0f, 0f, 0f)
+ }
+ }
+
+ override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
+ super.onSizeChanged(w, h, oldw, oldh)
+ if (dragDismissFraction > 0f) {
+ dragDismissDistance = h * dragDismissFraction
+ }
+ }
+
+ fun addListener(listener: ElasticDragDismissCallback) {
+ callbacks.add(listener)
+ }
+
+ fun removeListener(listener: ElasticDragDismissCallback) {
+ callbacks.remove(listener)
+ }
+
+ private fun dragScale(scroll: Int) {
+ if (scroll == 0) return
+
+ totalDrag += scroll.toFloat()
+
+ // track the direction & set the pivot point for scaling
+ // don't double track i.e. if start dragging down and then reverse, keep tracking as
+ // dragging down until they reach the 'natural' position
+ if (scroll < 0 && !draggingUp && !draggingDown) {
+ draggingDown = true
+ if (shouldScale) pivotY = height.toFloat()
+ } else if (scroll > 0 && !draggingDown && !draggingUp) {
+ draggingUp = true
+ if (shouldScale) pivotY = 0f
+ }
+ // how far have we dragged relative to the distance to perform a dismiss
+ // (0–1 where 1 = dismiss distance). Decreasing logarithmically as we approach the limit
+ var dragFraction = Math.log10((1 + Math.abs(totalDrag) / dragDismissDistance).toDouble()).toFloat()
+
+ // calculate the desired translation given the drag fraction
+ var dragTo = dragFraction * dragDismissDistance * dragElacticity
+
+ if (draggingUp) {
+ // as we use the absolute magnitude when calculating the drag fraction, need to
+ // re-apply the drag direction
+ dragTo *= -1f
+ }
+ translationY = dragTo
+
+ if (shouldScale) {
+ val scale = 1 - (1 - dragDismissScale) * dragFraction
+ scaleX = scale
+ scaleY = scale
+ }
+
+ // if we've reversed direction and gone past the settle point then clear the flags to
+ // allow the list to get the scroll events & reset any transforms
+ if (draggingDown && totalDrag >= 0 || draggingUp && totalDrag <= 0) {
+ dragFraction = 0f
+ dragTo = dragFraction
+ totalDrag = dragTo
+ draggingUp = false
+ draggingDown = draggingUp
+ translationY = 0f
+ scaleX = 1f
+ scaleY = 1f
+ }
+ dispatchDragCallback(dragFraction, dragTo,
+ Math.min(1f, Math.abs(totalDrag) / dragDismissDistance), totalDrag)
+ }
+
+ private fun dispatchDragCallback(elasticOffset: Float, elasticOffsetPixels: Float,
+ rawOffset: Float, rawOffsetPixels: Float) {
+ callbacks.forEach {
+ it.onDrag(elasticOffset, elasticOffsetPixels,
+ rawOffset, rawOffsetPixels)
+ }
+ }
+
+ private fun dispatchDismissCallback() {
+ callbacks.forEach { it.onDragDismissed() }
+ }
+
+ /**
+ * An [ElasticDragDismissCallback] which fades system chrome (i.e. status bar and
+ * navigation bar) whilst elastic drags are performed and
+ * [finishes][Activity.finishAfterTransition] the activity when drag dismissed.
+ */
+ open class SystemChromeFader(private val activity: Activity) : ElasticDragDismissCallback() {
+ private val statusBarAlpha: Int = Color.alpha(activity.statusBarColor)
+ private val navBarAlpha: Int = Color.alpha(activity.navigationBarColor)
+ private val fadeNavBar: Boolean = activity.isNavBarOnBottom
+
+ public override fun onDrag(elasticOffset: Float, elasticOffsetPixels: Float,
+ rawOffset: Float, rawOffsetPixels: Float) {
+ if (elasticOffsetPixels > 0) {
+ // dragging downward, fade the status bar in proportion
+ activity.statusBarColor = activity.statusBarColor.withAlpha(((1f - rawOffset) * statusBarAlpha).toInt())
+ } else if (elasticOffsetPixels == 0f) {
+ // reset
+ activity.statusBarColor = activity.statusBarColor.withAlpha(statusBarAlpha)
+ activity.navigationBarColor = activity.navigationBarColor.withAlpha(navBarAlpha)
+ } else if (fadeNavBar) {
+ // dragging upward, fade the navigation bar in proportion
+ activity.navigationBarColor = activity.navigationBarColor.withAlpha(((1f - rawOffset) * navBarAlpha).toInt())
+ }
+ }
+
+ public override fun onDragDismissed() {
+ activity.finishAfterTransition()
+ }
+ }
+
+}
diff --git a/core-ui/src/main/kotlin/ca/allanwang/kau/ui/widgets/InkPageIndicator.java b/core-ui/src/main/kotlin/ca/allanwang/kau/ui/widgets/InkPageIndicator.java
new file mode 100644
index 0000000..cc90cb2
--- /dev/null
+++ b/core-ui/src/main/kotlin/ca/allanwang/kau/ui/widgets/InkPageIndicator.java
@@ -0,0 +1,859 @@
+/*
+ * Copyright 2015 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 ca.allanwang.kau.ui.widgets;
+
+import android.animation.Animator;
+import android.animation.AnimatorListenerAdapter;
+import android.animation.AnimatorSet;
+import android.animation.ValueAnimator;
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.database.DataSetObserver;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.Path;
+import android.graphics.RectF;
+import android.support.annotation.ColorInt;
+import android.support.v4.view.ViewPager;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.view.View;
+import android.view.animation.Interpolator;
+
+import java.util.Arrays;
+
+import ca.allanwang.kau.R;
+import ca.allanwang.kau.utils.AnimHolder;
+import ca.allanwang.kau.utils.ColorUtilsKt;
+
+/**
+ * An ink inspired widget for indicating pages in a {@link ViewPager}.
+ */
+public class InkPageIndicator extends View implements ViewPager.OnPageChangeListener,
+ View.OnAttachStateChangeListener {
+
+ // defaults
+ private static final int DEFAULT_DOT_SIZE = 8; // dp
+ private static final int DEFAULT_GAP = 12; // dp
+ private static final int DEFAULT_ANIM_DURATION = 400; // ms
+ private static final int DEFAULT_UNSELECTED_COLOUR = 0x80ffffff; // 50% white
+ private static final int DEFAULT_SELECTED_COLOUR = 0xffffffff; // 100% white
+
+ // constants
+ private static final float INVALID_FRACTION = -1f;
+ private static final float MINIMAL_REVEAL = 0.00001f;
+
+ // configurable attributes
+ private int dotDiameter;
+ private int gap;
+ private long animDuration;
+ private int unselectedColour;
+ private int selectedColour;
+
+ public void setColour(@ColorInt int color) {
+ selectedColour = color;
+ unselectedColour = ColorUtilsKt.adjustAlpha(color, 0.5f);
+ selectedPaint.setColor(selectedColour);
+ unselectedPaint.setColor(unselectedColour);
+ }
+
+ // derived from attributes
+ private float dotRadius;
+ private float halfDotRadius;
+ private long animHalfDuration;
+ private float dotTopY;
+ private float dotCenterY;
+ private float dotBottomY;
+
+ // ViewPager
+ private ViewPager viewPager;
+
+ // state
+ private int pageCount;
+ private int currentPage;
+ private int previousPage;
+ private float selectedDotX;
+ private boolean selectedDotInPosition;
+ private float[] dotCenterX;
+ private float[] joiningFractions;
+ private float retreatingJoinX1;
+ private float retreatingJoinX2;
+ private float[] dotRevealFractions;
+ private boolean isAttachedToWindow;
+ private boolean pageChanging;
+
+ // drawing
+ private final Paint unselectedPaint;
+ private final Paint selectedPaint;
+ private final Path combinedUnselectedPath;
+ private final Path unselectedDotPath;
+ private final Path unselectedDotLeftPath;
+ private final Path unselectedDotRightPath;
+ private final RectF rectF;
+
+ // animation
+ private ValueAnimator moveAnimation;
+ private AnimatorSet joiningAnimationSet;
+ private PendingRetreatAnimator retreatAnimation;
+ private PendingRevealAnimator[] revealAnimations;
+ private final Interpolator interpolator;
+
+ // working values for beziers
+ float endX1;
+ float endY1;
+ float endX2;
+ float endY2;
+ float controlX1;
+ float controlY1;
+ float controlX2;
+ float controlY2;
+
+ public InkPageIndicator(Context context) {
+ this(context, null, 0);
+ }
+
+ public InkPageIndicator(Context context, AttributeSet attrs) {
+ this(context, attrs, 0);
+ }
+
+ public InkPageIndicator(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+
+ final int density = (int) context.getResources().getDisplayMetrics().density;
+
+ // Load attributes
+ final TypedArray a = getContext().obtainStyledAttributes(
+ attrs, R.styleable.InkPageIndicator, defStyle, 0);
+
+ dotDiameter = a.getDimensionPixelSize(R.styleable.InkPageIndicator_dotDiameter,
+ DEFAULT_DOT_SIZE * density);
+ dotRadius = dotDiameter / 2;
+ halfDotRadius = dotRadius / 2;
+ gap = a.getDimensionPixelSize(R.styleable.InkPageIndicator_dotGap,
+ DEFAULT_GAP * density);
+ animDuration = (long) a.getInteger(R.styleable.InkPageIndicator_animationDuration,
+ DEFAULT_ANIM_DURATION);
+ animHalfDuration = animDuration / 2;
+ unselectedColour = a.getColor(R.styleable.InkPageIndicator_pageIndicatorColor,
+ DEFAULT_UNSELECTED_COLOUR);
+ selectedColour = a.getColor(R.styleable.InkPageIndicator_currentPageIndicatorColor,
+ DEFAULT_SELECTED_COLOUR);
+
+ a.recycle();
+
+ unselectedPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ unselectedPaint.setColor(unselectedColour);
+ selectedPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ selectedPaint.setColor(selectedColour);
+ interpolator = AnimHolder.INSTANCE.getFastOutSlowInInterpolator().invoke(context);
+
+ // create paths & rect now – reuse & rewind later
+ combinedUnselectedPath = new Path();
+ unselectedDotPath = new Path();
+ unselectedDotLeftPath = new Path();
+ unselectedDotRightPath = new Path();
+ rectF = new RectF();
+
+ addOnAttachStateChangeListener(this);
+ }
+
+ public void setViewPager(ViewPager viewPager) {
+ this.viewPager = viewPager;
+ viewPager.addOnPageChangeListener(this);
+ setPageCount(viewPager.getAdapter().getCount());
+ viewPager.getAdapter().registerDataSetObserver(new DataSetObserver() {
+ @Override
+ public void onChanged() {
+ setPageCount(InkPageIndicator.this.viewPager.getAdapter().getCount());
+ }
+ });
+ setCurrentPageImmediate();
+ }
+
+ @Override
+ public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
+ if (isAttachedToWindow) {
+ float fraction = positionOffset;
+ int currentPosition = pageChanging ? previousPage : currentPage;
+ int leftDotPosition = position;
+ // when swiping from #2 to #1 ViewPager reports position as 1 and a descending offset
+ // need to convert this into our left-dot-based 'coordinate space'
+ if (currentPosition != position) {
+ fraction = 1f - positionOffset;
+
+ // if user scrolls completely to next page then the position param updates to that
+ // new page but we're not ready to switch our 'current' page yet so adjust for that
+ if (fraction == 1f) {
+ leftDotPosition = Math.min(currentPosition, position);
+ }
+ }
+ setJoiningFraction(leftDotPosition, fraction);
+ }
+ }
+
+ @Override
+ public void onPageSelected(int position) {
+ if (isAttachedToWindow) {
+ // this is the main event we're interested in!
+ setSelectedPage(position);
+ } else {
+ // when not attached, don't animate the move, just store immediately
+ setCurrentPageImmediate();
+ }
+ }
+
+ @Override
+ public void onPageScrollStateChanged(int state) {
+ // nothing to do
+ }
+
+ private void setPageCount(int pages) {
+ pageCount = pages;
+ resetState();
+ requestLayout();
+ }
+
+ private void calculateDotPositions(int width, int height) {
+ int left = getPaddingLeft();
+ int top = getPaddingTop();
+ int right = width - getPaddingRight();
+ int bottom = height - getPaddingBottom();
+
+ int requiredWidth = getRequiredWidth();
+ float startLeft = left + ((right - left - requiredWidth) / 2) + dotRadius;
+
+ dotCenterX = new float[pageCount];
+ for (int i = 0; i < pageCount; i++) {
+ dotCenterX[i] = startLeft + i * (dotDiameter + gap);
+ }
+ // todo just top aligning for now… should make this smarter
+ dotTopY = top;
+ dotCenterY = top + dotRadius;
+ dotBottomY = top + dotDiameter;
+
+ setCurrentPageImmediate();
+ }
+
+ private void setCurrentPageImmediate() {
+ if (viewPager != null) {
+ currentPage = viewPager.getCurrentItem();
+ } else {
+ currentPage = 0;
+ }
+ if (dotCenterX != null) {
+ selectedDotX = dotCenterX[currentPage];
+ }
+ }
+
+ private void resetState() {
+ joiningFractions = new float[pageCount - 1];
+ Arrays.fill(joiningFractions, 0f);
+ dotRevealFractions = new float[pageCount];
+ Arrays.fill(dotRevealFractions, 0f);
+ retreatingJoinX1 = INVALID_FRACTION;
+ retreatingJoinX2 = INVALID_FRACTION;
+ selectedDotInPosition = true;
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+
+ int desiredHeight = getDesiredHeight();
+ int height;
+ switch (MeasureSpec.getMode(heightMeasureSpec)) {
+ case MeasureSpec.EXACTLY:
+ height = MeasureSpec.getSize(heightMeasureSpec);
+ break;
+ case MeasureSpec.AT_MOST:
+ height = Math.min(desiredHeight, MeasureSpec.getSize(heightMeasureSpec));
+ break;
+ case MeasureSpec.UNSPECIFIED:
+ default:
+ height = desiredHeight;
+ break;
+ }
+
+ int desiredWidth = getDesiredWidth();
+ int width;
+ switch (MeasureSpec.getMode(widthMeasureSpec)) {
+ case MeasureSpec.EXACTLY:
+ width = MeasureSpec.getSize(widthMeasureSpec);
+ break;
+ case MeasureSpec.AT_MOST:
+ width = Math.min(desiredWidth, MeasureSpec.getSize(widthMeasureSpec));
+ break;
+ case MeasureSpec.UNSPECIFIED:
+ default:
+ width = desiredWidth;
+ break;
+ }
+ setMeasuredDimension(width, height);
+ calculateDotPositions(width, height);
+ }
+
+ private int getDesiredHeight() {
+ return getPaddingTop() + dotDiameter + getPaddingBottom();
+ }
+
+ private int getRequiredWidth() {
+ return pageCount * dotDiameter + (pageCount - 1) * gap;
+ }
+
+ private int getDesiredWidth() {
+ return getPaddingLeft() + getRequiredWidth() + getPaddingRight();
+ }
+
+ @Override
+ public void onViewAttachedToWindow(View view) {
+ isAttachedToWindow = true;
+ }
+
+ @Override
+ public void onViewDetachedFromWindow(View view) {
+ isAttachedToWindow = false;
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ if (viewPager == null || pageCount == 0) return;
+ drawUnselected(canvas);
+ drawSelected(canvas);
+ }
+
+ private void drawUnselected(Canvas canvas) {
+
+ combinedUnselectedPath.rewind();
+
+ // draw any settled, revealing or joining dots
+ for (int page = 0; page < pageCount; page++) {
+ int nextXIndex = page == pageCount - 1 ? page : page + 1;
+ combinedUnselectedPath.op(getUnselectedPath(page,
+ dotCenterX[page],
+ dotCenterX[nextXIndex],
+ page == pageCount - 1 ? INVALID_FRACTION : joiningFractions[page],
+ dotRevealFractions[page]), Path.Op.UNION);
+ }
+ // draw any retreating joins
+ if (retreatingJoinX1 != INVALID_FRACTION) {
+ combinedUnselectedPath.op(getRetreatingJoinPath(), Path.Op.UNION);
+ }
+ canvas.drawPath(combinedUnselectedPath, unselectedPaint);
+ }
+
+ /**
+ * Unselected dots can be in 6 states:
+ * <p>
+ * #1 At rest
+ * #2 Joining neighbour, still separate
+ * #3 Joining neighbour, combined curved
+ * #4 Joining neighbour, combined straight
+ * #5 Join retreating
+ * #6 Dot re-showing / revealing
+ * <p>
+ * It can also be in a combination of these states e.g. joining one neighbour while
+ * retreating from another. We therefore create a Path so that we can examine each
+ * dot pair separately and later take the union for these cases.
+ * <p>
+ * This function returns a path for the given dot **and any action to it's right** e.g. joining
+ * or retreating from it's neighbour
+ *
+ * @param page
+ * @return
+ */
+ private Path getUnselectedPath(int page,
+ float centerX,
+ float nextCenterX,
+ float joiningFraction,
+ float dotRevealFraction) {
+
+ unselectedDotPath.rewind();
+
+ if ((joiningFraction == 0f || joiningFraction == INVALID_FRACTION)
+ && dotRevealFraction == 0f
+ && !(page == currentPage && selectedDotInPosition == true)) {
+
+ // case #1 – At rest
+ unselectedDotPath.addCircle(dotCenterX[page], dotCenterY, dotRadius, Path.Direction.CW);
+ }
+
+ if (joiningFraction > 0f && joiningFraction <= 0.5f
+ && retreatingJoinX1 == INVALID_FRACTION) {
+
+ // case #2 – Joining neighbour, still separate
+
+ // start with the left dot
+ unselectedDotLeftPath.rewind();
+
+ // start at the bottom center
+ unselectedDotLeftPath.moveTo(centerX, dotBottomY);
+
+ // semi circle to the top center
+ rectF.set(centerX - dotRadius, dotTopY, centerX + dotRadius, dotBottomY);
+ unselectedDotLeftPath.arcTo(rectF, 90, 180, true);
+
+ // cubic to the right middle
+ endX1 = centerX + dotRadius + (joiningFraction * gap);
+ endY1 = dotCenterY;
+ controlX1 = centerX + halfDotRadius;
+ controlY1 = dotTopY;
+ controlX2 = endX1;
+ controlY2 = endY1 - halfDotRadius;
+ unselectedDotLeftPath.cubicTo(controlX1, controlY1,
+ controlX2, controlY2,
+ endX1, endY1);
+
+ // cubic back to the bottom center
+ endX2 = centerX;
+ endY2 = dotBottomY;
+ controlX1 = endX1;
+ controlY1 = endY1 + halfDotRadius;
+ controlX2 = centerX + halfDotRadius;
+ controlY2 = dotBottomY;
+ unselectedDotLeftPath.cubicTo(controlX1, controlY1,
+ controlX2, controlY2,
+ endX2, endY2);
+
+ unselectedDotPath.op(unselectedDotLeftPath, Path.Op.UNION);
+
+ // now do the next dot to the right
+ unselectedDotRightPath.rewind();
+
+ // start at the bottom center
+ unselectedDotRightPath.moveTo(nextCenterX, dotBottomY);
+
+ // semi circle to the top center
+ rectF.set(nextCenterX - dotRadius, dotTopY, nextCenterX + dotRadius, dotBottomY);
+ unselectedDotRightPath.arcTo(rectF, 90, -180, true);
+
+ // cubic to the left middle
+ endX1 = nextCenterX - dotRadius - (joiningFraction * gap);
+ endY1 = dotCenterY;
+ controlX1 = nextCenterX - halfDotRadius;
+ controlY1 = dotTopY;
+ controlX2 = endX1;
+ controlY2 = endY1 - halfDotRadius;
+ unselectedDotRightPath.cubicTo(controlX1, controlY1,
+ controlX2, controlY2,
+ endX1, endY1);
+
+ // cubic back to the bottom center
+ endX2 = nextCenterX;
+ endY2 = dotBottomY;
+ controlX1 = endX1;
+ controlY1 = endY1 + halfDotRadius;
+ controlX2 = endX2 - halfDotRadius;
+ controlY2 = dotBottomY;
+ unselectedDotRightPath.cubicTo(controlX1, controlY1,
+ controlX2, controlY2,
+ endX2, endY2);
+ unselectedDotPath.op(unselectedDotRightPath, Path.Op.UNION);
+ }
+
+ if (joiningFraction > 0.5f && joiningFraction < 1f
+ && retreatingJoinX1 == INVALID_FRACTION) {
+
+ // case #3 – Joining neighbour, combined curved
+
+ // adjust the fraction so that it goes from 0.3 -> 1 to produce a more realistic 'join'
+ float adjustedFraction = (joiningFraction - 0.2f) * 1.25f;
+
+ // start in the bottom left
+ unselectedDotPath.moveTo(centerX, dotBottomY);
+
+ // semi-circle to the top left
+ rectF.set(centerX - dotRadius, dotTopY, centerX + dotRadius, dotBottomY);
+ unselectedDotPath.arcTo(rectF, 90, 180, true);
+
+ // bezier to the middle top of the join
+ endX1 = centerX + dotRadius + (gap / 2);
+ endY1 = dotCenterY - (adjustedFraction * dotRadius);
+ controlX1 = endX1 - (adjustedFraction * dotRadius);
+ controlY1 = dotTopY;
+ controlX2 = endX1 - ((1 - adjustedFraction) * dotRadius);
+ controlY2 = endY1;
+ unselectedDotPath.cubicTo(controlX1, controlY1,
+ controlX2, controlY2,
+ endX1, endY1);
+
+ // bezier to the top right of the join
+ endX2 = nextCenterX;
+ endY2 = dotTopY;
+ controlX1 = endX1 + ((1 - adjustedFraction) * dotRadius);
+ controlY1 = endY1;
+ controlX2 = endX1 + (adjustedFraction * dotRadius);
+ controlY2 = dotTopY;
+ unselectedDotPath.cubicTo(controlX1, controlY1,
+ controlX2, controlY2,
+ endX2, endY2);
+
+ // semi-circle to the bottom right
+ rectF.set(nextCenterX - dotRadius, dotTopY, nextCenterX + dotRadius, dotBottomY);
+ unselectedDotPath.arcTo(rectF, 270, 180, true);
+
+ // bezier to the middle bottom of the join
+ // endX1 stays the same
+ endY1 = dotCenterY + (adjustedFraction * dotRadius);
+ controlX1 = endX1 + (adjustedFraction * dotRadius);
+ controlY1 = dotBottomY;
+ controlX2 = endX1 + ((1 - adjustedFraction) * dotRadius);
+ controlY2 = endY1;
+ unselectedDotPath.cubicTo(controlX1, controlY1,
+ controlX2, controlY2,
+ endX1, endY1);
+
+ // bezier back to the start point in the bottom left
+ endX2 = centerX;
+ endY2 = dotBottomY;
+ controlX1 = endX1 - ((1 - adjustedFraction) * dotRadius);
+ controlY1 = endY1;
+ controlX2 = endX1 - (adjustedFraction * dotRadius);
+ controlY2 = endY2;
+ unselectedDotPath.cubicTo(controlX1, controlY1,
+ controlX2, controlY2,
+ endX2, endY2);
+ }
+ if (joiningFraction == 1 && retreatingJoinX1 == INVALID_FRACTION) {
+
+ // case #4 Joining neighbour, combined straight technically we could use case 3 for this
+ // situation as well but assume that this is an optimization rather than faffing around
+ // with beziers just to draw a rounded rect
+ rectF.set(centerX - dotRadius, dotTopY, nextCenterX + dotRadius, dotBottomY);
+ unselectedDotPath.addRoundRect(rectF, dotRadius, dotRadius, Path.Direction.CW);
+ }
+
+ // case #5 is handled by #getRetreatingJoinPath()
+ // this is done separately so that we can have a single retreating path spanning
+ // multiple dots and therefore animate it's movement smoothly
+
+ if (dotRevealFraction > MINIMAL_REVEAL) {
+
+ // case #6 – previously hidden dot revealing
+ unselectedDotPath.addCircle(centerX, dotCenterY, dotRevealFraction * dotRadius,
+ Path.Direction.CW);
+ }
+
+ return unselectedDotPath;
+ }
+
+ private Path getRetreatingJoinPath() {
+ unselectedDotPath.rewind();
+ rectF.set(retreatingJoinX1, dotTopY, retreatingJoinX2, dotBottomY);
+ unselectedDotPath.addRoundRect(rectF, dotRadius, dotRadius, Path.Direction.CW);
+ return unselectedDotPath;
+ }
+
+ private void drawSelected(Canvas canvas) {
+ canvas.drawCircle(selectedDotX, dotCenterY, dotRadius, selectedPaint);
+ }
+
+ private void setSelectedPage(int now) {
+ if (now == currentPage) return;
+
+ pageChanging = true;
+ previousPage = currentPage;
+ currentPage = now;
+ final int steps = Math.abs(now - previousPage);
+
+ if (steps > 1) {
+ if (now > previousPage) {
+ for (int i = 0; i < steps; i++) {
+ setJoiningFraction(previousPage + i, 1f);
+ }
+ } else {
+ for (int i = -1; i > -steps; i--) {
+ setJoiningFraction(previousPage + i, 1f);
+ }
+ }
+ }
+
+ // create the anim to move the selected dot – this animator will kick off
+ // retreat animations when it has moved 75% of the way.
+ // The retreat animation in turn will kick of reveal anims when the
+ // retreat has passed any dots to be revealed
+ moveAnimation = createMoveSelectedAnimator(dotCenterX[now], previousPage, now, steps);
+ moveAnimation.start();
+ }
+
+ private ValueAnimator createMoveSelectedAnimator(
+ final float moveTo, int was, int now, int steps) {
+
+ // create the actual move animator
+ ValueAnimator moveSelected = ValueAnimator.ofFloat(selectedDotX, moveTo);
+
+ // also set up a pending retreat anim – this starts when the move is 75% complete
+ retreatAnimation = new PendingRetreatAnimator(was, now, steps,
+ now > was ?
+ new RightwardStartPredicate(moveTo - ((moveTo - selectedDotX) * 0.25f)) :
+ new LeftwardStartPredicate(moveTo + ((selectedDotX - moveTo) * 0.25f)));
+ retreatAnimation.addListener(new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ resetState();
+ pageChanging = false;
+ }
+ });
+ moveSelected.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
+ @Override
+ public void onAnimationUpdate(ValueAnimator valueAnimator) {
+ // todo avoid autoboxing
+ selectedDotX = (Float) valueAnimator.getAnimatedValue();
+ retreatAnimation.startIfNecessary(selectedDotX);
+ postInvalidateOnAnimation();
+ }
+ });
+ moveSelected.addListener(new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationStart(Animator animation) {
+ // set a flag so that we continue to draw the unselected dot in the target position
+ // until the selected dot has finished moving into place
+ selectedDotInPosition = false;
+ }
+
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ // set a flag when anim finishes so that we don't draw both selected & unselected
+ // page dots
+ selectedDotInPosition = true;
+ }
+ });
+ // slightly delay the start to give the joins a chance to run
+ // unless dot isn't in position yet – then don't delay!
+ moveSelected.setStartDelay(selectedDotInPosition ? animDuration / 4l : 0l);
+ moveSelected.setDuration(animDuration * 3l / 4l);
+ moveSelected.setInterpolator(interpolator);
+ return moveSelected;
+ }
+
+ private void setJoiningFraction(int leftDot, float fraction) {
+ if (leftDot < joiningFractions.length) {
+
+ if (leftDot == 1) {
+ Log.d("PageIndicator", "dot 1 fraction:\t" + fraction);
+ }
+
+ joiningFractions[leftDot] = fraction;
+ postInvalidateOnAnimation();
+ }
+ }
+
+ private void clearJoiningFractions() {
+ Arrays.fill(joiningFractions, 0f);
+ postInvalidateOnAnimation();
+ }
+
+ private void setDotRevealFraction(int dot, float fraction) {
+ dotRevealFractions[dot] = fraction;
+ postInvalidateOnAnimation();
+ }
+
+ private void cancelJoiningAnimations() {
+ if (joiningAnimationSet != null && joiningAnimationSet.isRunning()) {
+ joiningAnimationSet.cancel();
+ }
+ }
+
+ /**
+ * A {@link ValueAnimator} that starts once a given predicate returns true.
+ */
+ public abstract class PendingStartAnimator extends ValueAnimator {
+
+ protected boolean hasStarted;
+ protected StartPredicate predicate;
+
+ public PendingStartAnimator(StartPredicate predicate) {
+ super();
+ this.predicate = predicate;
+ hasStarted = false;
+ }
+
+ public void startIfNecessary(float currentValue) {
+ if (!hasStarted && predicate.shouldStart(currentValue)) {
+ start();
+ hasStarted = true;
+ }
+ }
+ }
+
+ /**
+ * An Animator that shows and then shrinks a retreating join between the previous and newly
+ * selected pages. This also sets up some pending dot reveals – to be started when the retreat
+ * has passed the dot to be revealed.
+ */
+ public class PendingRetreatAnimator extends PendingStartAnimator {
+
+ public PendingRetreatAnimator(int was, int now, int steps, StartPredicate predicate) {
+ super(predicate);
+ setDuration(animHalfDuration);
+ setInterpolator(interpolator);
+
+ // work out the start/end values of the retreating join from the direction we're
+ // travelling in. Also look at the current selected dot position, i.e. we're moving on
+ // before a prior anim has finished.
+ final float initialX1 = now > was ? Math.min(dotCenterX[was], selectedDotX) - dotRadius
+ : dotCenterX[now] - dotRadius;
+ final float finalX1 = now > was ? dotCenterX[now] - dotRadius
+ : dotCenterX[now] - dotRadius;
+ final float initialX2 = now > was ? dotCenterX[now] + dotRadius
+ : Math.max(dotCenterX[was], selectedDotX) + dotRadius;
+ final float finalX2 = now > was ? dotCenterX[now] + dotRadius
+ : dotCenterX[now] + dotRadius;
+
+ revealAnimations = new PendingRevealAnimator[steps];
+ // hold on to the indexes of the dots that will be hidden by the retreat so that
+ // we can initialize their revealFraction's i.e. make sure they're hidden while the
+ // reveal animation runs
+ final int[] dotsToHide = new int[steps];
+ if (initialX1 != finalX1) { // rightward retreat
+ setFloatValues(initialX1, finalX1);
+ // create the reveal animations that will run when the retreat passes them
+ for (int i = 0; i < steps; i++) {
+ revealAnimations[i] = new PendingRevealAnimator(was + i,
+ new RightwardStartPredicate(dotCenterX[was + i]));
+ dotsToHide[i] = was + i;
+ }
+ addUpdateListener(new AnimatorUpdateListener() {
+ @Override
+ public void onAnimationUpdate(ValueAnimator valueAnimator) {
+ // todo avoid autoboxing
+ retreatingJoinX1 = (Float) valueAnimator.getAnimatedValue();
+ postInvalidateOnAnimation();
+ // start any reveal animations if we've passed them
+ for (PendingRevealAnimator pendingReveal : revealAnimations) {
+ pendingReveal.startIfNecessary(retreatingJoinX1);
+ }
+ }
+ });
+ } else { // (initialX2 != finalX2) leftward retreat
+ setFloatValues(initialX2, finalX2);
+ // create the reveal animations that will run when the retreat passes them
+ for (int i = 0; i < steps; i++) {
+ revealAnimations[i] = new PendingRevealAnimator(was - i,
+ new LeftwardStartPredicate(dotCenterX[was - i]));
+ dotsToHide[i] = was - i;
+ }
+ addUpdateListener(new AnimatorUpdateListener() {
+ @Override
+ public void onAnimationUpdate(ValueAnimator valueAnimator) {
+ // todo avoid autoboxing
+ retreatingJoinX2 = (Float) valueAnimator.getAnimatedValue();
+ postInvalidateOnAnimation();
+ // start any reveal animations if we've passed them
+ for (PendingRevealAnimator pendingReveal : revealAnimations) {
+ pendingReveal.startIfNecessary(retreatingJoinX2);
+ }
+ }
+ });
+ }
+
+ addListener(new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationStart(Animator animation) {
+ cancelJoiningAnimations();
+ clearJoiningFractions();
+ // we need to set this so that the dots are hidden until the reveal anim runs
+ for (int dot : dotsToHide) {
+ setDotRevealFraction(dot, MINIMAL_REVEAL);
+ }
+ retreatingJoinX1 = initialX1;
+ retreatingJoinX2 = initialX2;
+ postInvalidateOnAnimation();
+ }
+
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ retreatingJoinX1 = INVALID_FRACTION;
+ retreatingJoinX2 = INVALID_FRACTION;
+ postInvalidateOnAnimation();
+ }
+ });
+ }
+ }
+
+ /**
+ * An Animator that animates a given dot's revealFraction i.e. scales it up
+ */
+ public class PendingRevealAnimator extends PendingStartAnimator {
+
+ private int dot;
+
+ public PendingRevealAnimator(int dot, StartPredicate predicate) {
+ super(predicate);
+ setFloatValues(MINIMAL_REVEAL, 1f);
+ this.dot = dot;
+ setDuration(animHalfDuration);
+ setInterpolator(interpolator);
+ addUpdateListener(new AnimatorUpdateListener() {
+ @Override
+ public void onAnimationUpdate(ValueAnimator valueAnimator) {
+ // todo avoid autoboxing
+ setDotRevealFraction(PendingRevealAnimator.this.dot,
+ (Float) valueAnimator.getAnimatedValue());
+ }
+ });
+ addListener(new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ setDotRevealFraction(PendingRevealAnimator.this.dot, 0f);
+ postInvalidateOnAnimation();
+ }
+ });
+ }
+ }
+
+ /**
+ * A predicate used to start an animation when a test passes
+ */
+ public abstract class StartPredicate {
+
+ protected float thresholdValue;
+
+ public StartPredicate(float thresholdValue) {
+ this.thresholdValue = thresholdValue;
+ }
+
+ abstract boolean shouldStart(float currentValue);
+
+ }
+
+ /**
+ * A predicate used to start an animation when a given value is greater than a threshold
+ */
+ public class RightwardStartPredicate extends StartPredicate {
+
+ public RightwardStartPredicate(float thresholdValue) {
+ super(thresholdValue);
+ }
+
+ boolean shouldStart(float currentValue) {
+ return currentValue > thresholdValue;
+ }
+ }
+
+ /**
+ * A predicate used to start an animation then a given value is less than a threshold
+ */
+ public class LeftwardStartPredicate extends StartPredicate {
+
+ public LeftwardStartPredicate(float thresholdValue) {
+ super(thresholdValue);
+ }
+
+ boolean shouldStart(float currentValue) {
+ return currentValue < thresholdValue;
+ }
+ }
+}
diff --git a/core-ui/src/main/kotlin/ca/allanwang/kau/ui/widgets/TextSlider.kt b/core-ui/src/main/kotlin/ca/allanwang/kau/ui/widgets/TextSlider.kt
new file mode 100644
index 0000000..f38a0b7
--- /dev/null
+++ b/core-ui/src/main/kotlin/ca/allanwang/kau/ui/widgets/TextSlider.kt
@@ -0,0 +1,125 @@
+package ca.allanwang.kau.ui.widgets
+
+import android.content.Context
+import android.graphics.Color
+import android.support.v4.widget.TextViewCompat
+import android.text.TextUtils
+import android.util.AttributeSet
+import android.view.Gravity
+import android.view.animation.Animation
+import android.view.animation.AnimationUtils
+import android.widget.TextSwitcher
+import android.widget.TextView
+import ca.allanwang.kau.R
+import java.util.*
+
+/**
+ * Created by Allan Wang on 2017-06-21.
+ *
+ * Text switcher with global text color and embedded sliding animations
+ * Also has a stack to keep track of title changes
+ */
+class TextSlider @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null
+) : TextSwitcher(context, attrs) {
+
+ val titleStack: Stack<CharSequence?> = Stack()
+
+ /**
+ * Holds a mapping of animation types to their respective animations
+ */
+ val animationMap = mapOf(
+ ANIMATION_NONE to null,
+ ANIMATION_SLIDE_HORIZONTAL to AnimationBundle(
+ R.anim.kau_slide_in_right, R.anim.kau_slide_out_left,
+ R.anim.kau_slide_in_left, R.anim.kau_slide_out_right),
+ ANIMATION_SLIDE_VERTICAL to AnimationBundle(
+ R.anim.kau_slide_in_bottom, R.anim.kau_slide_out_top,
+ R.anim.kau_slide_in_top, R.anim.kau_slide_out_bottom
+ )
+ )
+
+ /**
+ * Holds lazy instances of the animations
+ */
+ inner class AnimationBundle(private val nextIn: Int, private val nextOut: Int, private val prevIn: Int, private val prevOut: Int) {
+ val NEXT_IN: Animation by lazy { AnimationUtils.loadAnimation(context, nextIn) }
+ val NEXT_OUT: Animation by lazy { AnimationUtils.loadAnimation(context, nextOut) }
+ val PREV_IN: Animation by lazy { AnimationUtils.loadAnimation(context, prevIn) }
+ val PREV_OUT: Animation by lazy { AnimationUtils.loadAnimation(context, prevOut) }
+ }
+
+ companion object {
+ const val ANIMATION_NONE = 1000
+ const val ANIMATION_SLIDE_HORIZONTAL = 1001
+ const val ANIMATION_SLIDE_VERTICAL = 1002
+ }
+
+ var animationType: Int = ANIMATION_SLIDE_HORIZONTAL
+
+ var textColor: Int = Color.WHITE
+ get() = field
+ set(value) {
+ field = value
+ (getChildAt(0) as TextView).setTextColor(value)
+ (getChildAt(1) as TextView).setTextColor(value)
+ }
+ val isRoot: Boolean
+ get() = titleStack.size <= 1
+
+ init {
+ if (attrs != null) {
+ val styledAttrs = context.obtainStyledAttributes(attrs, R.styleable.TextSlider)
+ animationType = styledAttrs.getInteger(R.styleable.TextSlider_animation_type, ANIMATION_SLIDE_HORIZONTAL)
+ styledAttrs.recycle()
+ }
+ }
+
+ override fun setText(text: CharSequence?) {
+ if ((currentView as TextView).text == text) return
+ super.setText(text)
+ }
+
+ override fun setCurrentText(text: CharSequence?) {
+ if (titleStack.isNotEmpty()) titleStack.pop()
+ titleStack.push(text)
+ super.setCurrentText(text)
+ }
+
+ fun setNextText(text: CharSequence?) {
+ if (titleStack.isEmpty()) {
+ setCurrentText(text)
+ return
+ }
+ titleStack.push(text)
+ val anim = animationMap[animationType]
+ inAnimation = anim?.NEXT_IN
+ outAnimation = anim?.NEXT_OUT
+ setText(text)
+ }
+
+ /**
+ * Sets the text as the previous title
+ * No further checks are done, so be sure to verify with [isRoot]
+ */
+ @Throws(EmptyStackException::class)
+ fun setPrevText() {
+ titleStack.pop()
+ val anim = animationMap[animationType]
+ inAnimation = anim?.PREV_IN
+ outAnimation = anim?.PREV_OUT
+ val text = titleStack.peek()
+ setText(text)
+ }
+
+ init {
+ setFactory {
+ TextView(context).apply {
+ //replica of toolbar title
+ gravity = Gravity.START
+ setSingleLine()
+ ellipsize = TextUtils.TruncateAt.END
+ TextViewCompat.setTextAppearance(this, R.style.TextAppearance_AppCompat_Title)
+ }
+ }
+ }
+} \ No newline at end of file