From ae5c55dcecef397fa1f36a4c13dd16d01b1baf0d Mon Sep 17 00:00:00 2001 From: Allan Wang Date: Wed, 28 Jun 2017 18:49:15 -0700 Subject: Remove kau_ prefix for styleables --- .../ca/allanwang/kau/about/AboutActivityBase.kt | 16 +- .../kau/dialogs/color/ColorPickerDialog.kt | 4 +- .../kotlin/ca/allanwang/kau/kpref/KPrefActivity.kt | 6 +- .../ca/allanwang/kau/searchview/SearchView.kt | 4 +- .../ca/allanwang/kau/views/BoundedCardView.kt | 50 ++ .../ca/allanwang/kau/views/CutoutTextView.kt | 147 ++++ .../ca/allanwang/kau/views/KauBoundedCardView.kt | 50 -- .../ca/allanwang/kau/views/KauCutoutTextView.kt | 147 ---- .../kau/widgets/ElasticDragDismissFrameLayout.kt | 236 ++++++ .../ca/allanwang/kau/widgets/InkPageIndicator.java | 850 +++++++++++++++++++++ .../widgets/KauElasticDragDismissFrameLayout.kt | 245 ------ .../allanwang/kau/widgets/KauInkPageIndicator.java | 850 --------------------- .../ca/allanwang/kau/widgets/KauTextSlider.kt | 125 --- .../kotlin/ca/allanwang/kau/widgets/TextSlider.kt | 125 +++ .../src/main/res/layout/kau_about_section_main.xml | 6 +- library/src/main/res/layout/kau_activity_about.xml | 14 +- library/src/main/res/layout/kau_activity_kpref.xml | 4 +- .../main/res/layout/kau_recycler_textslider.xml | 4 +- library/src/main/res/layout/kau_search_view.xml | 6 +- library/src/main/res/values/attr.xml | 42 +- 20 files changed, 1461 insertions(+), 1470 deletions(-) create mode 100644 library/src/main/kotlin/ca/allanwang/kau/views/BoundedCardView.kt create mode 100644 library/src/main/kotlin/ca/allanwang/kau/views/CutoutTextView.kt delete mode 100644 library/src/main/kotlin/ca/allanwang/kau/views/KauBoundedCardView.kt delete mode 100644 library/src/main/kotlin/ca/allanwang/kau/views/KauCutoutTextView.kt create mode 100644 library/src/main/kotlin/ca/allanwang/kau/widgets/ElasticDragDismissFrameLayout.kt create mode 100644 library/src/main/kotlin/ca/allanwang/kau/widgets/InkPageIndicator.java delete mode 100644 library/src/main/kotlin/ca/allanwang/kau/widgets/KauElasticDragDismissFrameLayout.kt delete mode 100644 library/src/main/kotlin/ca/allanwang/kau/widgets/KauInkPageIndicator.java delete mode 100644 library/src/main/kotlin/ca/allanwang/kau/widgets/KauTextSlider.kt create mode 100644 library/src/main/kotlin/ca/allanwang/kau/widgets/TextSlider.kt (limited to 'library/src') diff --git a/library/src/main/kotlin/ca/allanwang/kau/about/AboutActivityBase.kt b/library/src/main/kotlin/ca/allanwang/kau/about/AboutActivityBase.kt index 476b7f8..63af3cf 100644 --- a/library/src/main/kotlin/ca/allanwang/kau/about/AboutActivityBase.kt +++ b/library/src/main/kotlin/ca/allanwang/kau/about/AboutActivityBase.kt @@ -17,9 +17,9 @@ import ca.allanwang.kau.logging.KL import ca.allanwang.kau.utils.bindView import ca.allanwang.kau.utils.dimenPixelSize import ca.allanwang.kau.utils.string -import ca.allanwang.kau.views.KauCutoutTextView -import ca.allanwang.kau.widgets.KauElasticDragDismissFrameLayout -import ca.allanwang.kau.widgets.KauInkPageIndicator +import ca.allanwang.kau.views.CutoutTextView +import ca.allanwang.kau.widgets.ElasticDragDismissFrameLayout +import ca.allanwang.kau.widgets.InkPageIndicator import com.mikepenz.aboutlibraries.Libs import com.mikepenz.aboutlibraries.entity.Library import com.mikepenz.fastadapter.commons.adapters.FastItemAdapter @@ -39,9 +39,9 @@ import java.security.InvalidParameterException */ abstract class AboutActivityBase(val rClass: Class<*>, val configBuilder: Configs.() -> Unit = {}) : AppCompatActivity() { - val draggableFrame: KauElasticDragDismissFrameLayout by bindView(R.id.about_draggable_frame) + val draggableFrame: ElasticDragDismissFrameLayout by bindView(R.id.about_draggable_frame) val pager: ViewPager by bindView(R.id.about_pager) - val indicator: KauInkPageIndicator by bindView(R.id.about_indicator) + val indicator: InkPageIndicator by bindView(R.id.about_indicator) val configs: Configs by lazy { Configs().apply { configBuilder() } } override fun onCreate(savedInstanceState: Bundle?) { @@ -52,7 +52,7 @@ abstract class AboutActivityBase(val rClass: Class<*>, val configBuilder: Config pageMargin = dimenPixelSize(R.dimen.kau_spacing_normal) } indicator.setViewPager(pager) - draggableFrame.addListener(object : KauElasticDragDismissFrameLayout.SystemChromeFader(this) { + draggableFrame.addListener(object : ElasticDragDismissFrameLayout.SystemChromeFader(this) { override fun onDragDismissed() { // if we drag dismiss downward then the default reversal of the enter // transition would slide content upward which looks weird. So reverse it. @@ -91,14 +91,14 @@ abstract class AboutActivityBase(val rClass: Class<*>, val configBuilder: Config fun inflateMainPage(layoutInflater: LayoutInflater, parent: ViewGroup): View { val v = layoutInflater.inflate(R.layout.kau_about_section_main, parent, false) postInflateMainPage( - v.findViewById(R.id.about_main_cutout), + v.findViewById(R.id.about_main_cutout), v.findViewById(R.id.about_main_bottom_container), v.findViewById(R.id.about_main_bottom_text) ) return v } - open fun postInflateMainPage(cutout: KauCutoutTextView, bottomContainer: FrameLayout, bottomText: TextView) { + open fun postInflateMainPage(cutout: CutoutTextView, bottomContainer: FrameLayout, bottomText: TextView) { with (configs) { cutout.text = string(cutoutTextRes, cutoutText) bottomText.text = string(mainPageTitleRes, mainPageTitle) diff --git a/library/src/main/kotlin/ca/allanwang/kau/dialogs/color/ColorPickerDialog.kt b/library/src/main/kotlin/ca/allanwang/kau/dialogs/color/ColorPickerDialog.kt index 07f5e17..7c57c26 100644 --- a/library/src/main/kotlin/ca/allanwang/kau/dialogs/color/ColorPickerDialog.kt +++ b/library/src/main/kotlin/ca/allanwang/kau/dialogs/color/ColorPickerDialog.kt @@ -71,8 +71,8 @@ fun Context.colorPickerDialog(contract: ColorContract): MaterialDialog { negativeText(contract.cancelText) if (contract.allowCustom) neutralText(contract.presetText) onPositive { dialog, _ -> contract.colorCallback?.invoke(view.selectedColor); dialog.dismiss() } - onNegative { dialog, _ -> view.backOrCancel() } - if (contract.allowCustom) onNeutral { dialog, _ -> view.toggleCustom() } + onNegative { _, _ -> view.backOrCancel() } + if (contract.allowCustom) onNeutral { _, _ -> view.toggleCustom() } showListener { view.refreshColors() } if (contract.theme != null) theme(contract.theme!!) build() diff --git a/library/src/main/kotlin/ca/allanwang/kau/kpref/KPrefActivity.kt b/library/src/main/kotlin/ca/allanwang/kau/kpref/KPrefActivity.kt index a2985f0..9a9f7d4 100644 --- a/library/src/main/kotlin/ca/allanwang/kau/kpref/KPrefActivity.kt +++ b/library/src/main/kotlin/ca/allanwang/kau/kpref/KPrefActivity.kt @@ -17,7 +17,7 @@ import ca.allanwang.kau.utils.bindView import ca.allanwang.kau.utils.resolveColor import ca.allanwang.kau.utils.statusBarColor import ca.allanwang.kau.utils.string -import ca.allanwang.kau.widgets.KauTextSlider +import ca.allanwang.kau.widgets.TextSlider import ca.allanwang.kau.views.RippleCanvas import com.mikepenz.fastadapter.commons.adapters.FastItemAdapter @@ -32,13 +32,13 @@ abstract class KPrefActivity : AppCompatActivity(), KPrefActivityContract { val bgCanvas: RippleCanvas by bindView(R.id.kau_ripple) val toolbarCanvas: RippleCanvas by bindView(R.id.kau_toolbar_ripple) val toolbar: Toolbar by bindView(R.id.kau_toolbar) - val toolbarTitle: KauTextSlider by bindView(R.id.kau_toolbar_text) + val toolbarTitle: TextSlider by bindView(R.id.kau_toolbar_text) val prefHolder: ViewAnimator by bindView(R.id.kau_holder) private lateinit var globalOptions: GlobalOptions var animate: Boolean = true set(value) { field = value - toolbarTitle.animationType = if (value) KauTextSlider.ANIMATION_SLIDE_HORIZONTAL else KauTextSlider.ANIMATION_NONE + toolbarTitle.animationType = if (value) TextSlider.ANIMATION_SLIDE_HORIZONTAL else TextSlider.ANIMATION_NONE } private val SLIDE_IN_LEFT_ITEMS: Animation by lazy { AnimationUtils.loadAnimation(this, R.anim.kau_slide_in_left) } diff --git a/library/src/main/kotlin/ca/allanwang/kau/searchview/SearchView.kt b/library/src/main/kotlin/ca/allanwang/kau/searchview/SearchView.kt index b2490ac..c077a06 100644 --- a/library/src/main/kotlin/ca/allanwang/kau/searchview/SearchView.kt +++ b/library/src/main/kotlin/ca/allanwang/kau/searchview/SearchView.kt @@ -21,7 +21,7 @@ import ca.allanwang.kau.animators.NoAnimator import ca.allanwang.kau.kotlin.nonReadable import ca.allanwang.kau.searchview.SearchView.Configs import ca.allanwang.kau.utils.* -import ca.allanwang.kau.views.KauBoundedCardView +import ca.allanwang.kau.views.BoundedCardView import com.jakewharton.rxbinding2.widget.RxTextView import com.mikepenz.fastadapter.commons.adapters.FastItemAdapter import com.mikepenz.google_material_typeface_library.GoogleMaterial @@ -207,7 +207,7 @@ class SearchView @JvmOverloads constructor( val configs = Configs() //views private val shadow: View by bindView(R.id.search_shadow) - private val card: KauBoundedCardView by bindView(R.id.search_cardview) + private val card: BoundedCardView by bindView(R.id.search_cardview) private val iconNav: ImageView by bindView(R.id.search_nav) private val editText: AppCompatEditText by bindView(R.id.search_edit_text) val textEvents: Observable diff --git a/library/src/main/kotlin/ca/allanwang/kau/views/BoundedCardView.kt b/library/src/main/kotlin/ca/allanwang/kau/views/BoundedCardView.kt new file mode 100644 index 0000000..0cb65d0 --- /dev/null +++ b/library/src/main/kotlin/ca/allanwang/kau/views/BoundedCardView.kt @@ -0,0 +1,50 @@ +package ca.allanwang.kau.views + +import android.content.Context +import android.graphics.Rect +import android.support.v7.widget.CardView +import android.util.AttributeSet +import ca.allanwang.kau.R +import ca.allanwang.kau.utils.parentViewGroup +import ca.allanwang.kau.utils.parentVisibleHeight + + +/** + * 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/library/src/main/kotlin/ca/allanwang/kau/views/CutoutTextView.kt b/library/src/main/kotlin/ca/allanwang/kau/views/CutoutTextView.kt new file mode 100644 index 0000000..b113c3d --- /dev/null +++ b/library/src/main/kotlin/ca/allanwang/kau/views/CutoutTextView.kt @@ -0,0 +1,147 @@ +/* + * 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.views + +import android.content.Context +import android.graphics.* +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.logging.KL +import ca.allanwang.kau.utils.dimenPixelSize +import ca.allanwang.kau.utils.getFont +import ca.allanwang.kau.utils.parentVisibleHeight + +/** + * A view which punches out some text from an opaque color block, allowing you to see through it. + */ +class CutoutTextView @JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 +) : View(context, attrs, defStyleAttr) { + private val textPaint: TextPaint = TextPaint(Paint.ANTI_ALIAS_FLAG) + private var cutout: Bitmap? = null + var foregroundColor = Color.MAGENTA + var text: String? = "Text" + var overlayType: Int = 0 //todo add vector overlay options + private var textSize: Float = 0f + private var textY: Float = 0f + private var textX: Float = 0f + 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.CutoutTextView, 0, 0) + if (a.hasValue(R.styleable.CutoutTextView_font)) + textPaint.typeface = context.getFont(a.getString(R.styleable.CutoutTextView_font)) + foregroundColor = a.getColor(R.styleable.CutoutTextView_foregroundColor, foregroundColor) + text = a.getString(R.styleable.CutoutTextView_android_text) ?: text + minHeight = a.getDimension(R.styleable.CutoutTextView_android_minHeight, minHeight) + heightPercentage = a.getFloat(R.styleable.CutoutTextView_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) + calculateTextPosition() + createBitmap() + KL.d("Size changed") + } + + private fun calculateTextPosition() { + val targetWidth = width / PHI + textSize = getSingleLineTextSize(text!!, textPaint, targetWidth, 0f, maxTextSize, + 0.5f, resources.displayMetrics) + textPaint.textSize = textSize + + // measuring text is fun :] see: https://chris.banes.me/2014/03/27/measuring-text/ + textX = (width - textPaint.measureText(text)) / 2 + val textBounds = Rect() + textPaint.getTextBounds(text, 0, text!!.length, textBounds) + val textHeight = textBounds.height().toFloat() + textY = (height + textHeight) / 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) + + if (high - low < precision) { + return low + } else if (maxLineWidth > targetWidth) { + return getSingleLineTextSize(text, paint, targetWidth, low, mid, precision, metrics) + } else if (maxLineWidth < targetWidth) { + return getSingleLineTextSize(text, paint, targetWidth, mid, high, precision, metrics) + } else { + return 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) + + // this is the magic – Clear mode punches out the bitmap + textPaint.xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR) + cutoutCanvas.drawText(text, textX, textY, textPaint) + } + + override fun onDraw(canvas: Canvas) { + canvas.drawBitmap(cutout!!, 0f, 0f, null) + } + + override fun hasOverlappingRendering(): Boolean = true + + companion object { + val PHI = 1.6182f + } +} diff --git a/library/src/main/kotlin/ca/allanwang/kau/views/KauBoundedCardView.kt b/library/src/main/kotlin/ca/allanwang/kau/views/KauBoundedCardView.kt deleted file mode 100644 index 60f5176..0000000 --- a/library/src/main/kotlin/ca/allanwang/kau/views/KauBoundedCardView.kt +++ /dev/null @@ -1,50 +0,0 @@ -package ca.allanwang.kau.views - -import android.content.Context -import android.graphics.Rect -import android.support.v7.widget.CardView -import android.util.AttributeSet -import ca.allanwang.kau.R -import ca.allanwang.kau.utils.parentViewGroup -import ca.allanwang.kau.utils.parentVisibleHeight - - -/** - * 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 KauBoundedCardView @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.KauBoundedCardView) - maxHeight = styledAttrs.getDimensionPixelSize(R.styleable.KauBoundedCardView_kau_maxHeight, -1) - maxHeightPercent = styledAttrs.getFloat(R.styleable.KauBoundedCardView_kau_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/library/src/main/kotlin/ca/allanwang/kau/views/KauCutoutTextView.kt b/library/src/main/kotlin/ca/allanwang/kau/views/KauCutoutTextView.kt deleted file mode 100644 index 8df604a..0000000 --- a/library/src/main/kotlin/ca/allanwang/kau/views/KauCutoutTextView.kt +++ /dev/null @@ -1,147 +0,0 @@ -/* - * 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.views - -import android.content.Context -import android.graphics.* -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.logging.KL -import ca.allanwang.kau.utils.dimenPixelSize -import ca.allanwang.kau.utils.getFont -import ca.allanwang.kau.utils.parentVisibleHeight - -/** - * A view which punches out some text from an opaque color block, allowing you to see through it. - */ -class KauCutoutTextView @JvmOverloads constructor( - context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 -) : View(context, attrs, defStyleAttr) { - private val textPaint: TextPaint = TextPaint(Paint.ANTI_ALIAS_FLAG) - private var cutout: Bitmap? = null - var foregroundColor = Color.MAGENTA - var text: String? = "Text" - var overlayType: Int = 0 //todo add vector overlay options - private var textSize: Float = 0f - private var textY: Float = 0f - private var textX: Float = 0f - 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.KauCutoutTextView, 0, 0) - if (a.hasValue(R.styleable.KauCutoutTextView_kau_font)) - textPaint.typeface = context.getFont(a.getString(R.styleable.KauCutoutTextView_kau_font)) - foregroundColor = a.getColor(R.styleable.KauCutoutTextView_kau_foregroundColor, foregroundColor) - text = a.getString(R.styleable.KauCutoutTextView_android_text) ?: text - minHeight = a.getDimension(R.styleable.KauCutoutTextView_android_minHeight, minHeight) - heightPercentage = a.getFloat(R.styleable.KauCutoutTextView_kau_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) - calculateTextPosition() - createBitmap() - KL.d("Size changed") - } - - private fun calculateTextPosition() { - val targetWidth = width / PHI - textSize = getSingleLineTextSize(text!!, textPaint, targetWidth, 0f, maxTextSize, - 0.5f, resources.displayMetrics) - textPaint.textSize = textSize - - // measuring text is fun :] see: https://chris.banes.me/2014/03/27/measuring-text/ - textX = (width - textPaint.measureText(text)) / 2 - val textBounds = Rect() - textPaint.getTextBounds(text, 0, text!!.length, textBounds) - val textHeight = textBounds.height().toFloat() - textY = (height + textHeight) / 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) - - if (high - low < precision) { - return low - } else if (maxLineWidth > targetWidth) { - return getSingleLineTextSize(text, paint, targetWidth, low, mid, precision, metrics) - } else if (maxLineWidth < targetWidth) { - return getSingleLineTextSize(text, paint, targetWidth, mid, high, precision, metrics) - } else { - return 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) - - // this is the magic – Clear mode punches out the bitmap - textPaint.xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR) - cutoutCanvas.drawText(text, textX, textY, textPaint) - } - - override fun onDraw(canvas: Canvas) { - canvas.drawBitmap(cutout!!, 0f, 0f, null) - } - - override fun hasOverlappingRendering(): Boolean = true - - companion object { - val PHI = 1.6182f - } -} diff --git a/library/src/main/kotlin/ca/allanwang/kau/widgets/ElasticDragDismissFrameLayout.kt b/library/src/main/kotlin/ca/allanwang/kau/widgets/ElasticDragDismissFrameLayout.kt new file mode 100644 index 0000000..081db22 --- /dev/null +++ b/library/src/main/kotlin/ca/allanwang/kau/widgets/ElasticDragDismissFrameLayout.kt @@ -0,0 +1,236 @@ +/* + * 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.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.logging.KL +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 + private var dragDismissDistance = Float.MAX_VALUE + private var dragDismissFraction = -1f + private var dragDismissScale = 1f + private var shouldScale = false + private var dragElacticity = 0.8f + + // state + private var totalDrag: Float = 0f + private var draggingDown = false + private var draggingUp = false + + private var callbacks: MutableList = 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) + shouldScale = dragDismissScale != 1f + 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) + KL.e("On $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/library/src/main/kotlin/ca/allanwang/kau/widgets/InkPageIndicator.java b/library/src/main/kotlin/ca/allanwang/kau/widgets/InkPageIndicator.java new file mode 100644 index 0000000..8bdfd5c --- /dev/null +++ b/library/src/main/kotlin/ca/allanwang/kau/widgets/InkPageIndicator.java @@ -0,0 +1,850 @@ +/* + * 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.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.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; + +/** + * 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; + + // 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().get(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: + *

+ * #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 + *

+ * 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. + *

+ * 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/library/src/main/kotlin/ca/allanwang/kau/widgets/KauElasticDragDismissFrameLayout.kt b/library/src/main/kotlin/ca/allanwang/kau/widgets/KauElasticDragDismissFrameLayout.kt deleted file mode 100644 index d186647..0000000 --- a/library/src/main/kotlin/ca/allanwang/kau/widgets/KauElasticDragDismissFrameLayout.kt +++ /dev/null @@ -1,245 +0,0 @@ -/* - * 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.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.logging.KL -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 KauElasticDragDismissFrameLayout @JvmOverloads constructor( - context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0, defStyleRes: Int = 0 -) : FrameLayout(context, attrs, defStyleAttr, defStyleRes) { - - // configurable attribs - private var dragDismissDistance = Float.MAX_VALUE - private var dragDismissFraction = -1f - private var dragDismissScale = 1f - private var shouldScale = false - private var dragElacticity = 0.8f - - // state - private var totalDrag: Float = 0f - private var draggingDown = false - private var draggingUp = false - - private var callbacks: MutableList = mutableListOf() - - init { - - val a = getContext().obtainStyledAttributes( - attrs, R.styleable.KauElasticDragDismissFrameLayout, 0, 0) - - if (a.hasValue(R.styleable.KauElasticDragDismissFrameLayout_kau_dragDismissDistance)) { - dragDismissDistance = a.getDimensionPixelSize(R.styleable.KauElasticDragDismissFrameLayout_kau_dragDismissDistance, 0).toFloat() - } else if (a.hasValue(R.styleable.KauElasticDragDismissFrameLayout_kau_dragDismissFraction)) { - dragDismissFraction = a.getFloat(R.styleable.KauElasticDragDismissFrameLayout_kau_dragDismissFraction, dragDismissFraction) - } - if (a.hasValue(R.styleable.KauElasticDragDismissFrameLayout_kau_dragDismissScale)) { - dragDismissScale = a.getFloat(R.styleable.KauElasticDragDismissFrameLayout_kau_dragDismissScale, dragDismissScale) - shouldScale = dragDismissScale != 1f - } - if (a.hasValue(R.styleable.KauElasticDragDismissFrameLayout_kau_dragElasticity)) { - dragElacticity = a.getFloat(R.styleable.KauElasticDragDismissFrameLayout_kau_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) - KL.e("On $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/library/src/main/kotlin/ca/allanwang/kau/widgets/KauInkPageIndicator.java b/library/src/main/kotlin/ca/allanwang/kau/widgets/KauInkPageIndicator.java deleted file mode 100644 index 1f2dc9a..0000000 --- a/library/src/main/kotlin/ca/allanwang/kau/widgets/KauInkPageIndicator.java +++ /dev/null @@ -1,850 +0,0 @@ -/* - * 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.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.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; - -/** - * An ink inspired widget for indicating pages in a {@link ViewPager}. - */ -public class KauInkPageIndicator 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; - - // 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 KauInkPageIndicator(Context context) { - this(context, null, 0); - } - - public KauInkPageIndicator(Context context, AttributeSet attrs) { - this(context, attrs, 0); - } - - public KauInkPageIndicator(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.KauInkPageIndicator, defStyle, 0); - - dotDiameter = a.getDimensionPixelSize(R.styleable.KauInkPageIndicator_kau_dotDiameter, - DEFAULT_DOT_SIZE * density); - dotRadius = dotDiameter / 2; - halfDotRadius = dotRadius / 2; - gap = a.getDimensionPixelSize(R.styleable.KauInkPageIndicator_kau_dotGap, - DEFAULT_GAP * density); - animDuration = (long) a.getInteger(R.styleable.KauInkPageIndicator_kau_animationDuration, - DEFAULT_ANIM_DURATION); - animHalfDuration = animDuration / 2; - unselectedColour = a.getColor(R.styleable.KauInkPageIndicator_kau_pageIndicatorColor, - DEFAULT_UNSELECTED_COLOUR); - selectedColour = a.getColor(R.styleable.KauInkPageIndicator_kau_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().get(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(KauInkPageIndicator.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: - *

- * #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 - *

- * 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. - *

- * 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/library/src/main/kotlin/ca/allanwang/kau/widgets/KauTextSlider.kt b/library/src/main/kotlin/ca/allanwang/kau/widgets/KauTextSlider.kt deleted file mode 100644 index 02b4912..0000000 --- a/library/src/main/kotlin/ca/allanwang/kau/widgets/KauTextSlider.kt +++ /dev/null @@ -1,125 +0,0 @@ -package ca.allanwang.kau.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 KauTextSlider @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null -) : TextSwitcher(context, attrs) { - - val titleStack: Stack = 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.KauTextSlider) - animationType = styledAttrs.getInteger(R.styleable.KauTextSlider_kau_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/library/src/main/kotlin/ca/allanwang/kau/widgets/TextSlider.kt b/library/src/main/kotlin/ca/allanwang/kau/widgets/TextSlider.kt new file mode 100644 index 0000000..528dabc --- /dev/null +++ b/library/src/main/kotlin/ca/allanwang/kau/widgets/TextSlider.kt @@ -0,0 +1,125 @@ +package ca.allanwang.kau.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 = 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/library/src/main/res/layout/kau_about_section_main.xml b/library/src/main/res/layout/kau_about_section_main.xml index 32b3f19..e501f60 100644 --- a/library/src/main/res/layout/kau_about_section_main.xml +++ b/library/src/main/res/layout/kau_about_section_main.xml @@ -12,13 +12,13 @@ android:layout_width="match_parent" android:layout_height="wrap_content"> - diff --git a/library/src/main/res/layout/kau_activity_about.xml b/library/src/main/res/layout/kau_activity_about.xml index a2db0e9..33cc781 100644 --- a/library/src/main/res/layout/kau_activity_about.xml +++ b/library/src/main/res/layout/kau_activity_about.xml @@ -1,13 +1,13 @@ - - + app:pageIndicatorColor="@color/kau_about_page_indicator_dark" + app:currentPageIndicatorColor="@color/kau_about_page_indicator_dark_selected" /> - + diff --git a/library/src/main/res/layout/kau_activity_kpref.xml b/library/src/main/res/layout/kau_activity_kpref.xml index b3c0f78..05cacb5 100644 --- a/library/src/main/res/layout/kau_activity_kpref.xml +++ b/library/src/main/res/layout/kau_activity_kpref.xml @@ -24,12 +24,12 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent"> - + app:animation_type="slide_horizontal" /> diff --git a/library/src/main/res/layout/kau_recycler_textslider.xml b/library/src/main/res/layout/kau_recycler_textslider.xml index f051902..1402cfb 100644 --- a/library/src/main/res/layout/kau_recycler_textslider.xml +++ b/library/src/main/res/layout/kau_recycler_textslider.xml @@ -13,12 +13,12 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent"> - + app:animation_type="slide_vertical" /> diff --git a/library/src/main/res/layout/kau_search_view.xml b/library/src/main/res/layout/kau_search_view.xml index cd1c4bd..ff9fd74 100644 --- a/library/src/main/res/layout/kau_search_view.xml +++ b/library/src/main/res/layout/kau_search_view.xml @@ -11,14 +11,14 @@ android:background="@color/kau_shadow_overlay" android:visibility="gone" /> - + app:maxHeightPercent="0.9"> - + \ No newline at end of file diff --git a/library/src/main/res/values/attr.xml b/library/src/main/res/values/attr.xml index 6a32807..daeb5e3 100644 --- a/library/src/main/res/values/attr.xml +++ b/library/src/main/res/values/attr.xml @@ -1,40 +1,40 @@ - + - - - + + + - - + + - - - - - - + + + + + + - - - - - + + + + + - - + + - - + + \ No newline at end of file -- cgit v1.2.3