diff options
Diffstat (limited to 'core-ui')
16 files changed, 1688 insertions, 0 deletions
diff --git a/core-ui/.gitignore b/core-ui/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/core-ui/.gitignore @@ -0,0 +1 @@ +/build diff --git a/core-ui/build.gradle b/core-ui/build.gradle new file mode 100644 index 0000000..996918f --- /dev/null +++ b/core-ui/build.gradle @@ -0,0 +1,63 @@ +plugins { + id 'com.gladed.androidgitversion' version '0.3.4' +} + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' +apply plugin: 'com.github.dcendents.android-maven' + +group = project.APP_GROUP + +android { + compileSdkVersion Integer.parseInt(project.TARGET_SDK) + buildToolsVersion project.BUILD_TOOLS + + androidGitVersion { + codeFormat = 'MMNNPPBB' + prefix 'v' + } + + defaultConfig { + minSdkVersion Integer.parseInt(project.MIN_SDK) + targetSdkVersion Integer.parseInt(project.TARGET_SDK) + versionCode androidGitVersion.code() + versionName androidGitVersion.name() + consumerProguardFiles 'progress-proguard.txt' + testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" + } + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + lintOptions { + abortOnError false + checkReleaseBuilds false + } + resourcePrefix "kau_" + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + test.java.srcDirs += 'src/test/kotlin' + } +} + +dependencies { + compile fileTree(dir: 'libs', include: ['*.jar']) + androidTestCompile('com.android.support.test.espresso:espresso-core:2.2.2', { + exclude group: 'com.android.support', module: 'support-annotations' + }) + testCompile 'junit:junit:4.12' + compile project(':core') + compile project(':adapter') + + compile "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + testCompile "org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version" + + compile "io.reactivex.rxjava2:rxkotlin:${RX_KOTLIN}" + compile "io.reactivex.rxjava2:rxandroid:${RX_ANDROID}" + compile "com.jakewharton.rxbinding2:rxbinding-kotlin:${RX_BINDING}" + compile "com.jakewharton.rxbinding2:rxbinding-appcompat-v7-kotlin:${RX_BINDING}" +} + +apply from: '../artifacts.gradle' diff --git a/core-ui/progress-proguard.txt b/core-ui/progress-proguard.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/core-ui/progress-proguard.txt @@ -0,0 +1 @@ + diff --git a/core-ui/src/androidTest/java/ca/allanwang/kau/ExampleInstrumentedTest.java b/core-ui/src/androidTest/java/ca/allanwang/kau/ExampleInstrumentedTest.java new file mode 100644 index 0000000..7b079b2 --- /dev/null +++ b/core-ui/src/androidTest/java/ca/allanwang/kau/ExampleInstrumentedTest.java @@ -0,0 +1,26 @@ +package ca.allanwang.kau; + +import android.content.Context; +import android.support.test.InstrumentationRegistry; +import android.support.test.runner.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.junit.Assert.*; + +/** + * Instrumentation test, which will execute on an Android device. + * + * @see <a href="http://d.android.com/tools/testing">Testing documentation</a> + */ +@RunWith(AndroidJUnit4.class) +public class ExampleInstrumentedTest { + @Test + public void useAppContext() throws Exception { + // Context of the app under test. + Context appContext = InstrumentationRegistry.getTargetContext(); + + assertEquals("ca.allanwang.kau.test", appContext.getPackageName()); + } +} diff --git a/core-ui/src/main/AndroidManifest.xml b/core-ui/src/main/AndroidManifest.xml new file mode 100644 index 0000000..5f9bd77 --- /dev/null +++ b/core-ui/src/main/AndroidManifest.xml @@ -0,0 +1 @@ +<manifest package="ca.allanwang.kau.ui" /> 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 diff --git a/core-ui/src/main/res/layout/kau_recycler_detached_background.xml b/core-ui/src/main/res/layout/kau_recycler_detached_background.xml new file mode 100644 index 0000000..7295d66 --- /dev/null +++ b/core-ui/src/main/res/layout/kau_recycler_detached_background.xml @@ -0,0 +1,27 @@ +<?xml version="1.0" encoding="utf-8"?> +<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:layout_width="match_parent" + android:layout_height="wrap_content"> + + <!-- we use a parallel view for the background rather than just setting a background on the + recycler view for a nicer return transition. i.e. we want the background to fade and the + list to slide out separately --> + <View + android:id="@+id/kau_recycler_detached_background" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:background="?android:colorBackground" /> + + <!--defaults to a LinearLayoutManager--> + + <android.support.v7.widget.RecyclerView + android:id="@+id/kau_recycler_detached" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:clipToPadding="false" + android:paddingBottom="@dimen/kau_spacing_normal" + android:scrollbars="vertical" + app:layoutManager="android.support.v7.widget.LinearLayoutManager" /> + +</FrameLayout> diff --git a/core-ui/src/main/res/layout/kau_recycler_textslider.xml b/core-ui/src/main/res/layout/kau_recycler_textslider.xml new file mode 100644 index 0000000..1402cfb --- /dev/null +++ b/core-ui/src/main/res/layout/kau_recycler_textslider.xml @@ -0,0 +1,34 @@ +<?xml version="1.0" encoding="utf-8"?> +<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:id="@+id/kau_container" + android:layout_width="match_parent" + android:layout_height="match_parent"> + + <android.support.v7.widget.Toolbar + android:id="@+id/kau_toolbar" + android:layout_width="0dp" + android:layout_height="?attr/actionBarSize" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent"> + + <ca.allanwang.kau.widgets.TextSlider + android:id="@+id/kau_toolbar_text" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:gravity="center" + app:animation_type="slide_vertical" /> + + </android.support.v7.widget.Toolbar> + + <android.support.v7.widget.RecyclerView + android:id="@+id/kau_recycler" + android:layout_width="0dp" + android:layout_height="0dp" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/kau_toolbar" /> + +</android.support.constraint.ConstraintLayout> diff --git a/core-ui/src/main/res/values/attr.xml b/core-ui/src/main/res/values/attr.xml new file mode 100644 index 0000000..49be8d9 --- /dev/null +++ b/core-ui/src/main/res/values/attr.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="ResourceName"> + + <declare-styleable name="BoundedCardView"> + <attr name="maxHeight" format="dimension" /> + <attr name="maxHeightPercent" format="float" /> + </declare-styleable> + + <declare-styleable name="TextSlider"> + <attr name="animation_type" format="enum"> + <enum name="none" value="1000" /> + <enum name="slide_horizontal" value="1001" /> + <enum name="slide_vertical" value="1002" /> + </attr> + </declare-styleable> + + <declare-styleable name="InkPageIndicator"> + <attr name="dotDiameter" format="dimension" /> + <attr name="dotGap" format="dimension" /> + <attr name="animationDuration" format="integer" /> + <attr name="pageIndicatorColor" format="color" /> + <attr name="currentPageIndicatorColor" format="color" /> + </declare-styleable> + + <declare-styleable name="ElasticDragDismissFrameLayout"> + <attr name="dragDismissDistance" format="dimension" /> + <attr name="dragDismissFraction" format="float" /> + <attr name="dragDismissScale" format="float" /> + <attr name="dragElasticity" format="float" /> + </declare-styleable> + + <declare-styleable name="CutoutView"> + <attr name="foregroundColor" format="color" /> + <attr name="android:text" /> + <attr name="android:drawable" /> + <attr name="android:minHeight" /> + <attr name="heightPercentageToScreen" format="float" /> + <attr name="font"/> + </declare-styleable> + +</resources>
\ No newline at end of file diff --git a/core-ui/src/main/res/values/colors.xml b/core-ui/src/main/res/values/colors.xml new file mode 100644 index 0000000..6d51597 --- /dev/null +++ b/core-ui/src/main/res/values/colors.xml @@ -0,0 +1,6 @@ +<resources> + <color name="kau_shadow_overlay">#80000000</color> + + <color name="kau_about_page_indicator_dark">#80ffffff</color> + <color name="kau_about_page_indicator_dark_selected">#fff</color> +</resources> diff --git a/core-ui/src/main/res/values/styles.xml b/core-ui/src/main/res/values/styles.xml new file mode 100644 index 0000000..f9fd7d4 --- /dev/null +++ b/core-ui/src/main/res/values/styles.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <style name="Kau" parent="Theme.AppCompat.NoActionBar" /> + + <style name="Kau.Translucent"> + <item name="android:windowBackground">@color/kau_shadow_overlay</item> + <item name="android:colorBackgroundCacheHint">@null</item> + <item name="android:windowContentOverlay">@null</item> + <item name="android:windowIsFloating">false</item> + <item name="android:windowIsTranslucent">true</item> + <item name="android:windowNoTitle">true</item> + <item name="android:windowDrawsSystemBarBackgrounds">true</item> + </style> + + <style name="Kau.Translucent.NoAnimation"> + <item name="android:windowAnimationStyle">@null</item> + </style> + +</resources>
\ No newline at end of file diff --git a/core-ui/src/test/java/ca/allanwang/kau/ExampleUnitTest.java b/core-ui/src/test/java/ca/allanwang/kau/ExampleUnitTest.java new file mode 100644 index 0000000..a29b447 --- /dev/null +++ b/core-ui/src/test/java/ca/allanwang/kau/ExampleUnitTest.java @@ -0,0 +1,17 @@ +package ca.allanwang.kau; + +import org.junit.Test; + +import static org.junit.Assert.*; + +/** + * Example local unit test, which will execute on the development machine (host). + * + * @see <a href="http://d.android.com/tools/testing">Testing documentation</a> + */ +public class ExampleUnitTest { + @Test + public void addition_isCorrect() throws Exception { + assertEquals(4, 2 + 2); + } +}
\ No newline at end of file |