From d52c787508bf5ba0023946228468731a82a1132d Mon Sep 17 00:00:00 2001 From: Allan Wang Date: Wed, 28 Jun 2017 17:08:20 -0700 Subject: Beginning of overlaying about activity --- .../ca/allanwang/kau/about/AboutActivityBase.kt | 181 +++-- .../kotlin/ca/allanwang/kau/about/LibraryItem.kt | 18 +- .../main/kotlin/ca/allanwang/kau/about/MainItem.kt | 68 -- .../kotlin/ca/allanwang/kau/kotlin/LazyContext.kt | 50 ++ .../ca/allanwang/kau/kotlin/LazyResettable.kt | 2 +- .../kotlin/ca/allanwang/kau/kpref/KPrefActivity.kt | 2 +- .../kotlin/ca/allanwang/kau/utils/AnimHolder.kt | 16 + .../kotlin/ca/allanwang/kau/utils/AnimUtils.kt | 8 +- .../kotlin/ca/allanwang/kau/utils/ContextUtils.kt | 32 +- .../kotlin/ca/allanwang/kau/utils/FontUtils.kt | 29 + .../kotlin/ca/allanwang/kau/utils/ViewUtils.kt | 23 +- .../ca/allanwang/kau/views/KauBoundedCardView.kt | 8 +- .../ca/allanwang/kau/views/KauCutoutTextView.kt | 147 ++++ .../kotlin/ca/allanwang/kau/views/KauTextSlider.kt | 125 --- .../widgets/KauElasticDragDismissFrameLayout.kt | 245 ++++++ .../allanwang/kau/widgets/KauInkPageIndicator.java | 850 +++++++++++++++++++++ .../ca/allanwang/kau/widgets/KauTextSlider.kt | 125 +++ 17 files changed, 1655 insertions(+), 274 deletions(-) delete mode 100644 library/src/main/kotlin/ca/allanwang/kau/about/MainItem.kt create mode 100644 library/src/main/kotlin/ca/allanwang/kau/kotlin/LazyContext.kt create mode 100644 library/src/main/kotlin/ca/allanwang/kau/utils/AnimHolder.kt create mode 100644 library/src/main/kotlin/ca/allanwang/kau/utils/FontUtils.kt create mode 100644 library/src/main/kotlin/ca/allanwang/kau/views/KauCutoutTextView.kt delete mode 100644 library/src/main/kotlin/ca/allanwang/kau/views/KauTextSlider.kt create mode 100644 library/src/main/kotlin/ca/allanwang/kau/widgets/KauElasticDragDismissFrameLayout.kt create mode 100644 library/src/main/kotlin/ca/allanwang/kau/widgets/KauInkPageIndicator.java create mode 100644 library/src/main/kotlin/ca/allanwang/kau/widgets/KauTextSlider.kt (limited to 'library/src/main/kotlin/ca') 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 4690718..476b7f8 100644 --- a/library/src/main/kotlin/ca/allanwang/kau/about/AboutActivityBase.kt +++ b/library/src/main/kotlin/ca/allanwang/kau/about/AboutActivityBase.kt @@ -1,84 +1,157 @@ package ca.allanwang.kau.about import android.os.Bundle +import android.support.v4.view.PagerAdapter +import android.support.v4.view.ViewPager import android.support.v7.app.AppCompatActivity +import android.support.v7.widget.LinearLayoutManager import android.support.v7.widget.RecyclerView -import android.support.v7.widget.Toolbar +import android.transition.TransitionInflater +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.TextView import ca.allanwang.kau.R -import ca.allanwang.kau.adapters.ChainedAdapters -import ca.allanwang.kau.adapters.SectionAdapter -import ca.allanwang.kau.animators.SlideUpAlphaAnimator +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.KauTextSlider +import ca.allanwang.kau.views.KauCutoutTextView +import ca.allanwang.kau.widgets.KauElasticDragDismissFrameLayout +import ca.allanwang.kau.widgets.KauInkPageIndicator import com.mikepenz.aboutlibraries.Libs import com.mikepenz.aboutlibraries.entity.Library +import com.mikepenz.fastadapter.commons.adapters.FastItemAdapter import org.jetbrains.anko.doAsync import org.jetbrains.anko.uiThread +import java.security.InvalidParameterException /** - * Created by Allan Wang on 2017-06-26. - * - * Customizable About Activity - * Will automatically include a section containing all of the libraries registered under About Libraries - * Takes in [rClass], which is the R.string::class.java for your app - * It is used to get the libs dynamically - * Make sure to add the following if you are using proguard: - * # About library - * -keep class .R - * -keep class **.R$* { - * ; - * } + * Created by Allan Wang on 2017-06-28. * + * Floating About Activity Panel for your app + * This contains all the necessary layouts, and can be extended and configured using the [configBuilder] + * The [rClass] is necessary to generate the list of libraries used in your app, and should point to your app's + * R.string::class.java + * Note that for the auto detection to work, the R fields must be excluded from Proguard + * Manual lib listings and other extra modifications can be done so by overriding the open functions */ -open class AboutActivityBase(val rClass: Class<*>) : AppCompatActivity() { +abstract class AboutActivityBase(val rClass: Class<*>, val configBuilder: Configs.() -> Unit = {}) : AppCompatActivity() { - val toolbar: Toolbar by bindView(R.id.kau_toolbar) - val toolbarText: KauTextSlider by bindView(R.id.kau_toolbar_text) - val recycler: RecyclerView by bindView(R.id.kau_recycler) - val libSection: Pair> by lazy { string(R.string.kau_dependencies_used) to SectionAdapter() } - val sectionsChain: ChainedAdapters = ChainedAdapters() + val draggableFrame: KauElasticDragDismissFrameLayout 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 configs: Configs by lazy { Configs().apply { configBuilder() } } - fun addLibsAsync() { - doAsync { - val libs = Libs(this@AboutActivityBase, Libs.toStringArray(rClass.fields)) - val items = getLibraries(libs) - uiThread { libSection.second.add(items.map { LibraryItem(it) }) } + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.kau_activity_about) + with(pager) { + adapter = AboutPagerAdapter() + pageMargin = dimenPixelSize(R.dimen.kau_spacing_normal) } + indicator.setViewPager(pager) + draggableFrame.addListener(object : KauElasticDragDismissFrameLayout.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. + if (draggableFrame.translationY > 0) { + window.returnTransition = TransitionInflater.from(this@AboutActivityBase) + .inflateTransition(configs.transitionExitReversed) + } + finishAfterTransition() + } + }) } - /** - * By default, the libraries will be extracted dynamically and sorted - * Override this to define your own list - */ - open fun getLibraries(libs: Libs): List = libs.prepareLibraries(this@AboutActivityBase, null, null, true, true) + inner class Configs { + var cutoutTextRes: Int = -1 + val cutoutText: String? = null + var mainPageTitleRes: Int = -1 + var mainPageTitle: String = "Kau test" + var libPageTitleRes: Int = -1 + var libPageTitle: String? = string(R.string.kau_about_libraries_intro) + var transitionExitReversed: Int = R.transition.kau_about_return_downward + } - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.kau_activity_about) - val sections = onCreateSections() - sectionsChain.add(sections) - if (!sections.contains(libSection)) sectionsChain.add(libSection) - sectionsChain.bindRecyclerView(recycler) { - item, _, dy -> - if (dy > 0) toolbarText.setNextText(item) - else toolbarText.setPrevText() + open fun getLibraries(libs: Libs): List = libs.prepareLibraries(this, null, null, true, true) + + open val pageCount: Int = 2 + + open fun getPage(position: Int, layoutInflater: LayoutInflater, parent: ViewGroup): View { + KL.e("Get page $position") + return when (position) { + 0 -> inflateMainPage(layoutInflater, parent) + pageCount - 1 -> inflateLibPage(layoutInflater, parent) + else -> throw InvalidParameterException() } - recycler.itemAnimator = SlideUpAlphaAnimator() - toolbarText.setCurrentText(sectionsChain[0].first) - onPostCreate() - addLibsAsync() } + 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_bottom_container), + v.findViewById(R.id.about_main_bottom_text) + ) + return v + } - open fun onPostCreate() { + open fun postInflateMainPage(cutout: KauCutoutTextView, bottomContainer: FrameLayout, bottomText: TextView) { + with (configs) { + cutout.text = string(cutoutTextRes, cutoutText) + bottomText.text = string(mainPageTitleRes, mainPageTitle) + } + } + fun inflateLibPage(layoutInflater: LayoutInflater, parent: ViewGroup): View { + val v = layoutInflater.inflate(R.layout.kau_about_section_libraries, parent, false) + postInflateLibPage( + v.findViewById(R.id.about_library_title), + v.findViewById(R.id.about_library_recycler) + ) + return v } - /** - * Get all the header adapters - * The adapters should be listed in the order that they appear, - * and if the [libSection] shouldn't be at the end, it should be added in this list - */ - open fun onCreateSections(): List>> = listOf(libSection) + open fun postInflateLibPage(title: TextView, recycler: RecyclerView) { + title.text = string(configs.libPageTitleRes, configs.libPageTitle) + val libAdapter = FastItemAdapter() + with(recycler) { + layoutManager = LinearLayoutManager(this@AboutActivityBase) + adapter = libAdapter + } + doAsync { + val libs = getLibraries(Libs(this@AboutActivityBase, Libs.toStringArray(rClass.fields))).map { LibraryItem(it) } + uiThread { libAdapter.add(libs) } + } + } + + inner class AboutPagerAdapter : PagerAdapter() { + + private val layoutInflater: LayoutInflater = LayoutInflater.from(this@AboutActivityBase) + private val views = Array(pageCount) { null } + + override fun instantiateItem(collection: ViewGroup, position: Int): Any { + val layout = getPage(position, collection) + KL.e("Add view") + collection.addView(layout) + return layout + } + + override fun destroyItem(collection: ViewGroup, position: Int, view: Any) { + collection.removeView(view as View) + views[position] = null + } + + override fun getCount(): Int = pageCount + + override fun isViewFromObject(view: View, `object`: Any): Boolean = view === `object` + + private fun getPage(position: Int, parent: ViewGroup): View { + KL.e("Create page $position ${views[position] == null}") + if (views[position] == null) views[position] = getPage(position, layoutInflater, parent) + return views[position]!! + } + } } \ No newline at end of file diff --git a/library/src/main/kotlin/ca/allanwang/kau/about/LibraryItem.kt b/library/src/main/kotlin/ca/allanwang/kau/about/LibraryItem.kt index 0dd4973..c3bfb5a 100644 --- a/library/src/main/kotlin/ca/allanwang/kau/about/LibraryItem.kt +++ b/library/src/main/kotlin/ca/allanwang/kau/about/LibraryItem.kt @@ -66,16 +66,16 @@ class LibraryItem(val lib: Library) : AbstractItem Unit) : AbstractItem() { - - val configs = Config().apply { builder() } - - class Config { - var icon: Drawable? = null - var title: String = "App Title" - var author: String? = null - var description: String? = null - var version: String? = null - var githubLink: String? = null - } - - override fun getType(): Int = R.id.kau_item_about_main - - override fun getLayoutRes(): Int = R.layout.kau_about_item_main - - override fun isSelectable(): Boolean = false - - override fun getViewHolder(v: View): ViewHolder = ViewHolder(v) - - override fun bindView(holder: ViewHolder, payloads: MutableList?) { - super.bindView(holder, payloads) - with(holder) { - title.text = configs.title - creator.text = configs.author - description.text = configs.description -// license.text = configs.description - } - } - - override fun unbindView(holder: ViewHolder) { - super.unbindView(holder) - with(holder) { - title.text = null - creator.text = null - description.text = null - } - } - - class ViewHolder(v: View) : RecyclerView.ViewHolder(v) { - val card: CardView by bindView(R.id.container) - val title: TextView by bindView(R.id.title) - val creator: TextView by bindView(R.id.creator) - val description: TextView by bindView(R.id.description) - val version: TextView by bindView(R.id.version) - val license: TextView by bindView(R.id.license) - val bottomContainer: LinearLayout by bindView(R.id.bottom_container) - - val divider: View by bindView(R.id.top_divider) - val bottomDivider: View by bindView(R.id.bottom_divider) - } -} diff --git a/library/src/main/kotlin/ca/allanwang/kau/kotlin/LazyContext.kt b/library/src/main/kotlin/ca/allanwang/kau/kotlin/LazyContext.kt new file mode 100644 index 0000000..a7dc09e --- /dev/null +++ b/library/src/main/kotlin/ca/allanwang/kau/kotlin/LazyContext.kt @@ -0,0 +1,50 @@ +package ca.allanwang.kau.kotlin + +import android.content.Context +import android.support.annotation.AnimatorRes +import android.support.annotation.InterpolatorRes +import android.view.animation.Animation +import android.view.animation.AnimationUtils +import android.view.animation.Interpolator + +/** + * Created by Allan Wang on 2017-05-30. + * + * Lazy retrieval of context based items + * Items are retrieved using delegateName[context] + * + */ + +fun lazyInterpolator(@InterpolatorRes id: Int) = lazyContext { AnimationUtils.loadInterpolator(it, id) } +fun lazyAnimation(@AnimatorRes id: Int) = lazyContext { AnimationUtils.loadAnimation(it, id) } + +fun lazyContext(initializer: (context: Context) -> T): LazyContext = LazyContext(initializer) + +class LazyContext(private val initializer: (context: Context) -> T, lock: Any? = null) { + @Volatile private var _value: Any = UNINITIALIZED + private val lock = lock ?: this + + fun invalidate() { + _value = UNINITIALIZED + } + + operator fun get(context: Context): T { + val _v1 = _value + if (_v1 !== UNINITIALIZED) + @Suppress("UNCHECKED_CAST") + return _v1 as T + + return synchronized(lock) { + val _v2 = _value + if (_v2 !== UNINITIALIZED) { + @Suppress("UNCHECKED_CAST") + _v2 as T + } else { + val typedValue = initializer(context) + _value = typedValue + typedValue + } + } + } + +} \ No newline at end of file diff --git a/library/src/main/kotlin/ca/allanwang/kau/kotlin/LazyResettable.kt b/library/src/main/kotlin/ca/allanwang/kau/kotlin/LazyResettable.kt index b74c5c7..f8947f3 100644 --- a/library/src/main/kotlin/ca/allanwang/kau/kotlin/LazyResettable.kt +++ b/library/src/main/kotlin/ca/allanwang/kau/kotlin/LazyResettable.kt @@ -9,7 +9,7 @@ import kotlin.reflect.KProperty * Lazy delegate that can be invalidated if needed * https://stackoverflow.com/a/37294840/4407321 */ -private object UNINITIALIZED +internal object UNINITIALIZED fun lazyResettable(initializer: () -> T): LazyResettable = LazyResettable(initializer) 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 193ca58..a2985f0 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.views.KauTextSlider +import ca.allanwang.kau.widgets.KauTextSlider import ca.allanwang.kau.views.RippleCanvas import com.mikepenz.fastadapter.commons.adapters.FastItemAdapter diff --git a/library/src/main/kotlin/ca/allanwang/kau/utils/AnimHolder.kt b/library/src/main/kotlin/ca/allanwang/kau/utils/AnimHolder.kt new file mode 100644 index 0000000..3739dec --- /dev/null +++ b/library/src/main/kotlin/ca/allanwang/kau/utils/AnimHolder.kt @@ -0,0 +1,16 @@ +package ca.allanwang.kau.utils + +import android.view.animation.AnimationUtils +import ca.allanwang.kau.kotlin.lazyContext +import ca.allanwang.kau.kotlin.lazyInterpolator + +/** + * Created by Allan Wang on 2017-06-28. + * + * Holder for a bunch of common animators/interpolators used throughout this library + */ +object AnimHolder { + + val fastOutSlowInInterpolator = lazyInterpolator(android.R.interpolator.fast_out_linear_in) + +} \ No newline at end of file diff --git a/library/src/main/kotlin/ca/allanwang/kau/utils/AnimUtils.kt b/library/src/main/kotlin/ca/allanwang/kau/utils/AnimUtils.kt index cde332a..86b049e 100644 --- a/library/src/main/kotlin/ca/allanwang/kau/utils/AnimUtils.kt +++ b/library/src/main/kotlin/ca/allanwang/kau/utils/AnimUtils.kt @@ -2,13 +2,16 @@ package ca.allanwang.kau.utils import android.animation.Animator import android.animation.AnimatorListenerAdapter +import android.content.Context import android.support.annotation.StringRes import android.view.View import android.view.ViewAnimationUtils import android.view.animation.Animation import android.view.animation.AnimationUtils import android.view.animation.DecelerateInterpolator +import android.view.animation.Interpolator import android.widget.TextView +import ca.allanwang.kau.kotlin.lazyContext /** * Created by Allan Wang on 2017-06-01. @@ -18,7 +21,7 @@ import android.widget.TextView @KauUtils fun View.rootCircularReveal(x: Int = 0, y: Int = 0, duration: Long = 500L, onStart: (() -> Unit)? = null, onFinish: (() -> Unit)? = null) { this.addOnLayoutChangeListener(object : View.OnLayoutChangeListener { override @KauUtils fun onLayoutChange(v: View, left: Int, top: Int, right: Int, bottom: Int, oldLeft: Int, oldTop: Int, - oldRight: Int, oldBottom: Int) { + oldRight: Int, oldBottom: Int) { v.removeOnLayoutChangeListener(this) var x2 = x var y2 = y @@ -147,5 +150,4 @@ import android.widget.TextView }) } -@KauUtils fun TextView.setTextWithFade(@StringRes textId: Int, duration: Long = 200, onFinish: (() -> Unit)? = null) = setTextWithFade(context.getString(textId), duration, onFinish) - +@KauUtils fun TextView.setTextWithFade(@StringRes textId: Int, duration: Long = 200, onFinish: (() -> Unit)? = null) = setTextWithFade(context.getString(textId), duration, onFinish) \ No newline at end of file diff --git a/library/src/main/kotlin/ca/allanwang/kau/utils/ContextUtils.kt b/library/src/main/kotlin/ca/allanwang/kau/utils/ContextUtils.kt index 31bff97..876f634 100644 --- a/library/src/main/kotlin/ca/allanwang/kau/utils/ContextUtils.kt +++ b/library/src/main/kotlin/ca/allanwang/kau/utils/ContextUtils.kt @@ -1,6 +1,7 @@ package ca.allanwang.kau.utils import android.app.Activity +import android.app.ActivityOptions import android.content.Context import android.content.Intent import android.graphics.drawable.Drawable @@ -12,20 +13,28 @@ import android.support.v4.app.ActivityOptionsCompat import android.support.v4.content.ContextCompat import android.util.TypedValue import android.view.View -import android.view.inputmethod.InputMethodManager import android.widget.Toast import ca.allanwang.kau.R +import ca.allanwang.kau.logging.KL import com.afollestad.materialdialogs.MaterialDialog /** * Created by Allan Wang on 2017-06-03. */ -fun Context.startActivity(clazz: Class, clearStack: Boolean = false, intentBuilder: Intent.() -> Unit = {}, bundle: Bundle? = null) { +fun Context.startActivity( + clazz: Class, + clearStack: Boolean = false, + transition: Boolean = false, + bundle: Bundle? = null, + intentBuilder: Intent.() -> Unit = {}) { val intent = (Intent(this, clazz)) if (clearStack) intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK) intent.intentBuilder() - ContextCompat.startActivity(this, intent, bundle) + val fullBundle = if (transition && this is Activity) ActivityOptions.makeSceneTransitionAnimation(this).toBundle() else Bundle() + if (transition && this !is Activity) KL.d("Cannot make scene transition when context is not an instance of an Activity") + if (bundle != null) fullBundle.putAll(bundle) + ContextCompat.startActivity(this, intent, if (fullBundle.isEmpty) null else fullBundle) if (this is Activity && clearStack) finish() } @@ -35,7 +44,7 @@ fun Context.startActivity(clazz: Class, clearStack: Boolean = fals fun Context.startActivitySlideIn(clazz: Class, clearStack: Boolean = false, intentBuilder: Intent.() -> Unit = {}, bundleBuilder: Bundle.() -> Unit = {}) { val bundle = ActivityOptionsCompat.makeCustomAnimation(this, R.anim.kau_slide_in_right, R.anim.kau_fade_out).toBundle() bundle.bundleBuilder() - startActivity(clazz, clearStack, intentBuilder, bundle) + startActivity(clazz, clearStack, intentBuilder = intentBuilder, bundle = bundle) } /** @@ -47,7 +56,7 @@ fun Context.startActivitySlideIn(clazz: Class, clearStack: Boolean fun Context.startActivitySlideOut(clazz: Class, clearStack: Boolean = true, intentBuilder: Intent.() -> Unit = {}, bundleBuilder: Bundle.() -> Unit = {}) { val bundle = ActivityOptionsCompat.makeCustomAnimation(this, R.anim.kau_fade_in, R.anim.kau_slide_out_right_top).toBundle() bundle.bundleBuilder() - startActivity(clazz, clearStack, intentBuilder, bundle) + startActivity(clazz, clearStack, intentBuilder = intentBuilder, bundle = bundle) } fun Context.startPlayStoreLink(@StringRes packageIdRes: Int) = startPlayStoreLink(string(packageIdRes)) @@ -71,6 +80,7 @@ fun Context.string(holder: StringHolder?): String? = holder?.getString(this) fun Context.color(@ColorRes id: Int): Int = ContextCompat.getColor(this, id) fun Context.integer(@IntegerRes id: Int): Int = resources.getInteger(id) fun Context.dimen(@DimenRes id: Int): Float = resources.getDimension(id) +fun Context.dimenPixelSize(@DimenRes id: Int): Int = resources.getDimensionPixelSize(id) fun Context.drawable(@DrawableRes id: Int): Drawable = ContextCompat.getDrawable(this, id) //Attr retrievers @@ -127,3 +137,15 @@ fun Context.getDip(value: Float): Float = TypedValue.applyDimension(TypedValue.C val Context.isRtl: Boolean get() = resources.configuration.layoutDirection == View.LAYOUT_DIRECTION_RTL + +/** + * Determine if the navigation bar will be on the bottom of the screen, based on logic in + * PhoneWindowManager. + */ +val Context.isNavBarOnBottom: Boolean + get() { + val cfg = resources.configuration + val dm = resources.displayMetrics + val canMove = dm.widthPixels != dm.heightPixels && cfg.smallestScreenWidthDp < 600 + return !canMove || dm.widthPixels < dm.heightPixels + } \ No newline at end of file diff --git a/library/src/main/kotlin/ca/allanwang/kau/utils/FontUtils.kt b/library/src/main/kotlin/ca/allanwang/kau/utils/FontUtils.kt new file mode 100644 index 0000000..3fc509d --- /dev/null +++ b/library/src/main/kotlin/ca/allanwang/kau/utils/FontUtils.kt @@ -0,0 +1,29 @@ +package ca.allanwang.kau.utils + +import android.content.Context +import android.graphics.Typeface + +/** + * Created by Allan Wang on 2017-06-28. + */ +object FontUtils { + + val sTypefaceCache: MutableMap = mutableMapOf() + + fun get(context: Context, font: String): Typeface { + synchronized(sTypefaceCache) { + if (!sTypefaceCache.containsKey(font)) { + val tf = Typeface.createFromAsset( + context.applicationContext.assets, "fonts/$font.ttf") + sTypefaceCache.put(font, tf) + } + return sTypefaceCache.get(font) ?: throw IllegalArgumentException("Font error; typeface does not exist at assets/fonts$font.ttf") + } + } + + fun getName(typeface: Typeface): String? = sTypefaceCache.entries.firstOrNull { it.value == typeface }?.key + +} + +fun Context.getFont(font: String) = FontUtils.get(this, font) +fun Context.getFontName(typeface: Typeface) = FontUtils.getName(typeface) \ No newline at end of file diff --git a/library/src/main/kotlin/ca/allanwang/kau/utils/ViewUtils.kt b/library/src/main/kotlin/ca/allanwang/kau/utils/ViewUtils.kt index 84d7e50..0f070d1 100644 --- a/library/src/main/kotlin/ca/allanwang/kau/utils/ViewUtils.kt +++ b/library/src/main/kotlin/ca/allanwang/kau/utils/ViewUtils.kt @@ -2,6 +2,8 @@ package ca.allanwang.kau.utils import android.content.Context import android.graphics.Color +import android.graphics.Outline +import android.graphics.Rect import android.support.annotation.ColorInt import android.support.annotation.StringRes import android.support.design.widget.Snackbar @@ -9,9 +11,11 @@ import android.support.transition.AutoTransition import android.support.transition.TransitionManager import android.view.View import android.view.ViewGroup +import android.view.ViewOutlineProvider import android.view.inputmethod.InputMethodManager import android.widget.ImageView import android.widget.TextView +import ca.allanwang.kau.logging.KL import ca.allanwang.kau.views.createSimpleRippleDrawable import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.typeface.IIcon @@ -78,4 +82,21 @@ fun View.snackbar(@StringRes textId: Int, duration: Int = Snackbar.LENGTH_LONG, } @KauUtils val View.parentViewGroup: ViewGroup - get() = parent as ViewGroup \ No newline at end of file + get() = parent as ViewGroup + +@KauUtils val View.parentVisibleHeight: Int + get() { + val r = Rect() + parentViewGroup.getWindowVisibleDisplayFrame(r) + return r.height() + } + +val CIRCULAR_OUTLINE: ViewOutlineProvider = object : ViewOutlineProvider() { + override fun getOutline(view: View, outline: Outline) { + KL.d("CIRCULAR OUTLINE") + outline.setOval(view.paddingLeft, + view.paddingTop, + view.width - view.paddingRight, + view.height - view.paddingBottom) + } +} \ No newline at end of file diff --git a/library/src/main/kotlin/ca/allanwang/kau/views/KauBoundedCardView.kt b/library/src/main/kotlin/ca/allanwang/kau/views/KauBoundedCardView.kt index a07a118..60f5176 100644 --- a/library/src/main/kotlin/ca/allanwang/kau/views/KauBoundedCardView.kt +++ b/library/src/main/kotlin/ca/allanwang/kau/views/KauBoundedCardView.kt @@ -6,6 +6,7 @@ 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 /** @@ -19,13 +20,6 @@ class KauBoundedCardView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : CardView(context, attrs, defStyleAttr) { - val parentVisibleHeight: Int - get() { - val r = Rect() - parentViewGroup.getWindowVisibleDisplayFrame(r) - return r.height() - } - /** * Maximum height possible, defined in dp (will be converted to px) * Defaults to parent's visible height diff --git a/library/src/main/kotlin/ca/allanwang/kau/views/KauCutoutTextView.kt b/library/src/main/kotlin/ca/allanwang/kau/views/KauCutoutTextView.kt new file mode 100644 index 0000000..8df604a --- /dev/null +++ b/library/src/main/kotlin/ca/allanwang/kau/views/KauCutoutTextView.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 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/views/KauTextSlider.kt b/library/src/main/kotlin/ca/allanwang/kau/views/KauTextSlider.kt deleted file mode 100644 index 8294f9f..0000000 --- a/library/src/main/kotlin/ca/allanwang/kau/views/KauTextSlider.kt +++ /dev/null @@ -1,125 +0,0 @@ -package ca.allanwang.kau.views - -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/KauElasticDragDismissFrameLayout.kt b/library/src/main/kotlin/ca/allanwang/kau/widgets/KauElasticDragDismissFrameLayout.kt new file mode 100644 index 0000000..d186647 --- /dev/null +++ b/library/src/main/kotlin/ca/allanwang/kau/widgets/KauElasticDragDismissFrameLayout.kt @@ -0,0 +1,245 @@ +/* + * 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 new file mode 100644 index 0000000..1f2dc9a --- /dev/null +++ b/library/src/main/kotlin/ca/allanwang/kau/widgets/KauInkPageIndicator.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 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 new file mode 100644 index 0000000..02b4912 --- /dev/null +++ b/library/src/main/kotlin/ca/allanwang/kau/widgets/KauTextSlider.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 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 -- cgit v1.2.3