diff options
author | Allan Wang <me@allanwang.ca> | 2017-07-04 16:08:03 -0700 |
---|---|---|
committer | Allan Wang <me@allanwang.ca> | 2017-07-04 16:08:03 -0700 |
commit | cf2a7fcd0880a8d276970124cdb5d5845d5631fe (patch) | |
tree | cc38ead7853ddb85c9c988e94a4af605e1e676f8 /core/src/main/kotlin | |
parent | fe4632c34a1d671503e0242a269865b884545e13 (diff) | |
download | kau-cf2a7fcd0880a8d276970124cdb5d5845d5631fe.tar.gz kau-cf2a7fcd0880a8d276970124cdb5d5845d5631fe.tar.bz2 kau-cf2a7fcd0880a8d276970124cdb5d5845d5631fe.zip |
Separate core components in its own module
Diffstat (limited to 'core/src/main/kotlin')
67 files changed, 7553 insertions, 0 deletions
diff --git a/core/src/main/kotlin/ca/allanwang/kau/about/AboutActivityBase.kt b/core/src/main/kotlin/ca/allanwang/kau/about/AboutActivityBase.kt new file mode 100644 index 0000000..32e8745 --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/about/AboutActivityBase.kt @@ -0,0 +1,236 @@ +package ca.allanwang.kau.about + +import android.graphics.drawable.Drawable +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.RecyclerView +import android.transition.TransitionInflater +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import ca.allanwang.kau.R +import ca.allanwang.kau.adapters.FastItemThemedAdapter +import ca.allanwang.kau.adapters.ThemableIItemColors +import ca.allanwang.kau.adapters.ThemableIItemColorsDelegate +import ca.allanwang.kau.animators.FadeScaleAnimator +import ca.allanwang.kau.iitems.CutoutIItem +import ca.allanwang.kau.iitems.HeaderIItem +import ca.allanwang.kau.iitems.LibraryIItem +import ca.allanwang.kau.utils.* +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.IItem +import org.jetbrains.anko.doAsync +import org.jetbrains.anko.uiThread +import java.security.InvalidParameterException + +/** + * 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 + */ +abstract class AboutActivityBase(val rClass: Class<*>, val configBuilder: Configs.() -> Unit = {}) : AppCompatActivity(), ViewPager.OnPageChangeListener { + + val draggableFrame: ElasticDragDismissFrameLayout by bindView(R.id.about_draggable_frame) + val pager: ViewPager by bindView(R.id.about_pager) + val indicator: InkPageIndicator by bindView(R.id.about_indicator) + /** + * Holds some common configurations that may be added directly from the constructor + * Applied lazily since it needs the context to fetch resources + */ + val configs: Configs by lazy { Configs().apply { configBuilder() } } + /** + * Number of pages in the adapter + * Defaults to just the main view and lib view + */ + open val pageCount: Int = 2 + /** + * Page position for the libs + * This is generated automatically if [inflateLibPage] is called + */ + private var libPage: Int = -2 + /** + * Holds that status of each page + * 0 means nothing has happened + * 1 means this page has been in view at least once + * The rest is up to you + */ + lateinit var pageStatus: IntArray + /** + * Holds the lib items once they are fetched asynchronously + */ + var libItems: List<LibraryIItem>? = null + /** + * Holds the adapter for the library page; this is generated later because it uses the config colors + */ + lateinit var libAdapter: FastItemThemedAdapter<IItem<*, *>> + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.kau_activity_about) + pageStatus = IntArray(pageCount) + libAdapter = FastItemThemedAdapter(configs) + LibraryIItem.bindClickEvents(libAdapter) + if (configs.textColor != null) indicator.setColour(configs.textColor!!) + with(pager) { + adapter = AboutPagerAdapter() + pageMargin = dimenPixelSize(R.dimen.kau_spacing_normal) + addOnPageChangeListener(this@AboutActivityBase) + } + indicator.setViewPager(pager) + 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. + if (draggableFrame.translationY > 0) { + window.returnTransition = TransitionInflater.from(this@AboutActivityBase) + .inflateTransition(configs.transitionExitReversed) + } + finishAfterTransition() + } + }) + } + + inner class Configs : ThemableIItemColors by ThemableIItemColorsDelegate() { + var cutoutTextRes: Int = -1 + var cutoutText: String? = null + var cutoutDrawableRes: Int = -1 + var cutoutDrawable: Drawable? = null + var cutoutForeground: Int? = null + var libPageTitleRes: Int = -1 + var libPageTitle: String? = string(R.string.kau_about_libraries_intro) //This is in the string by default since it's lower priority + /** + * Transition to be called if the view is dragged down + */ + var transitionExitReversed: Int = R.transition.kau_about_return_downward + } + + /** + * Method to fetch the library list + * This is fetched asynchronously and you may override it to customize the list + */ + open fun getLibraries(libs: Libs): List<Library> = libs.prepareLibraries(this, null, null, true, true) + + /** + * Gets the view associated with the given page position + * Keep in mind that when inflating, do NOT add the view to the viewgroup + * Use layoutInflater.inflate(id, parent, false) + */ + open fun getPage(position: Int, layoutInflater: LayoutInflater, parent: ViewGroup): View { + return when (position) { + 0 -> inflateMainPage(layoutInflater, parent, position) + pageCount - 1 -> inflateLibPage(layoutInflater, parent, position) + else -> throw InvalidParameterException() + } + } + + /** + * Create the main view with the cutout + */ + open fun inflateMainPage(layoutInflater: LayoutInflater, parent: ViewGroup, position: Int): View { + val fastAdapter = FastItemThemedAdapter<IItem<*, *>>(configs) + val recycler = fullLinearRecycler(fastAdapter) + fastAdapter.add(CutoutIItem { + with(configs) { + text = string(cutoutTextRes, cutoutText) + drawable = drawable(cutoutDrawableRes, cutoutDrawable) + if (configs.cutoutForeground != null) foregroundColor = configs.cutoutForeground!! + } + }.apply { + themeEnabled = configs.cutoutForeground == null + }) + postInflateMainPage(fastAdapter) + return recycler + } + + /** + * Open hook called just before the main page view is returned + * Feel free to add your own items to the adapter in here + */ + open fun postInflateMainPage(adapter: FastItemThemedAdapter<IItem<*, *>>) { + + } + + /** + * Create the lib view with the list of libraries + */ + open fun inflateLibPage(layoutInflater: LayoutInflater, parent: ViewGroup, position: Int): View { + libPage = position + val v = layoutInflater.inflate(R.layout.kau_recycler_detached_background, parent, false) + val recycler = v.findViewById<RecyclerView>(R.id.kau_recycler_detached) + recycler.adapter = libAdapter + recycler.itemAnimator = FadeScaleAnimator(itemDelayFactor = 0.2f).apply { addDuration = 300; interpolator = AnimHolder.decelerateInterpolator(this@AboutActivityBase) } + val background = v.findViewById<View>(R.id.kau_recycler_detached_background) + if (configs.backgroundColor != null) background.setBackgroundColor(configs.backgroundColor!!.colorToForeground()) + doAsync { + libItems = getLibraries(Libs(this@AboutActivityBase, Libs.toStringArray(rClass.fields))).map { LibraryIItem(it) } + if (libPage >= 0 && pageStatus[libPage] == 1) + uiThread { addLibItems() } + } + return v + } + + inner class AboutPagerAdapter : PagerAdapter() { + + private val layoutInflater: LayoutInflater = LayoutInflater.from(this@AboutActivityBase) + private val views = Array<View?>(pageCount) { null } + + override fun instantiateItem(collection: ViewGroup, position: Int): Any { + val layout = getPage(position, collection) + 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` + + /** + * Only get page if view does not exist + */ + private fun getPage(position: Int, parent: ViewGroup): View { + if (views[position] == null) views[position] = getPage(position, layoutInflater, parent) + return views[position]!! + } + } + + override fun onPageScrollStateChanged(state: Int) {} + + override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {} + + override fun onPageSelected(position: Int) { + if (pageStatus[position] == 0) pageStatus[position] = 1 // mark as seen if previously null + if (position == libPage && libItems != null && pageStatus[position] == 1) { + pageStatus[position] = 2 //add libs and mark as such + postDelayed(300) { addLibItems() } //delay so that the animations occur once the page is fully switched + } + } + + /** + * Function that is called when the view is ready to add the lib items + * Feel free to add your own items here + */ + open fun addLibItems() { + libAdapter.add(HeaderIItem(text = configs.libPageTitle, textRes = configs.libPageTitleRes)) + .add(libItems) + } + + override fun onDestroy() { + AnimHolder.decelerateInterpolator.invalidate() //clear the reference to the interpolators we've used + super.onDestroy() + } +}
\ No newline at end of file diff --git a/core/src/main/kotlin/ca/allanwang/kau/adapters/ChainedAdapters.kt b/core/src/main/kotlin/ca/allanwang/kau/adapters/ChainedAdapters.kt new file mode 100644 index 0000000..e1c5c18 --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/adapters/ChainedAdapters.kt @@ -0,0 +1,85 @@ +package ca.allanwang.kau.adapters + +import android.support.v7.widget.LinearLayoutManager +import android.support.v7.widget.RecyclerView +import com.mikepenz.fastadapter.IItem +import com.mikepenz.fastadapter.adapters.HeaderAdapter +import com.mikepenz.fastadapter.commons.adapters.FastItemAdapter +import org.jetbrains.anko.collections.forEachReversedWithIndex +import java.util.* + +/** + * Created by Allan Wang on 2017-06-27. + * + * Once bounded to a [RecyclerView], this will + * - Chain together a list of [HeaderAdapter]s, backed by a generic [FastItemAdapter] + * - Add a [LinearLayoutManager] to the recycler + * - Add a listener for when a new adapter segment is being used + */ +class ChainedAdapters<T>(vararg items: Pair<T, SectionAdapter<*>>) { + private val chain: MutableList<Pair<T, SectionAdapter<*>>> = mutableListOf(*items) + val baseAdapter: FastItemAdapter<IItem<*, *>> = FastItemAdapter() + private val indexStack = Stack<Int>() + var recycler: RecyclerView? = null + val firstVisibleItemPosition: Int + get() = (recycler?.layoutManager as LinearLayoutManager?)?.findFirstVisibleItemPosition() ?: throw IllegalArgumentException("No recyclerview was bounded to the chain adapters") + + fun add(vararg items: Pair<T, SectionAdapter<*>>) = add(items.toList()) + + fun add(items: Collection<Pair<T, SectionAdapter<*>>>): ChainedAdapters<T> { + if (recycler != null) throw IllegalAccessException("Chain adapter is already bounded to a recycler; cannot add directly.") + items.map { it.second }.forEachIndexed { index, sectionAdapter -> sectionAdapter.sectionOrder = chain.size + 1 + index } + chain.addAll(items) + return this + } + + operator fun get(index: Int) = chain[index] + + /** + * Attaches the chain to a recycler + * After this stage, any modifications to the adapters must be done through external references + * You may still get the generic header adapters through the get operator + * Binding the recycler also involves supplying a callback, which returns + * the item (T) associated with the adapter, + * the index (Int) of the current adapter + * and the dy (Int) as given by the scroll listener + */ + fun bindRecyclerView(recyclerView: RecyclerView, onAdapterSectionChanged: (item: T, index: Int, dy: Int) -> Unit) { + if (recycler != null) throw IllegalStateException("Chain adapter is already bounded") + if (chain.isEmpty()) throw IllegalArgumentException("No adapters have been added to the adapters list") + //wrap adapters + chain.map { it.second }.forEachReversedWithIndex { i, headerAdapter -> + if (i == chain.size - 1) headerAdapter.wrap(baseAdapter) + else headerAdapter.wrap(chain[i + 1].second) + } + recycler = recyclerView + indexStack.push(0) + with(recyclerView) { + layoutManager = LinearLayoutManager(context) + adapter = chain.first().second + addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) { + super.onScrolled(rv, dx, dy) + val topPosition = firstVisibleItemPosition + val currentAdapterIndex = indexStack.peek() + if (dy > 0) { + //look ahead from current adapter + val nextAdapterIndex = (currentAdapterIndex until chain.size).asSequence() + .firstOrNull { + val adapter = chain[it].second + adapter.adapterItemCount > 0 && adapter.getGlobalPosition(adapter.adapterItemCount - 1) >= topPosition + } ?: currentAdapterIndex + if (nextAdapterIndex == currentAdapterIndex) return + indexStack.push(nextAdapterIndex) + onAdapterSectionChanged(chain[indexStack.peek()].first, indexStack.peek(), dy) + } else if (currentAdapterIndex == 0) { + return //All adapters may be empty; in this case, if we are already at the beginning, don't bother checking + } else if (chain[currentAdapterIndex].second.getGlobalPosition(0) > topPosition) { + indexStack.pop() + onAdapterSectionChanged(chain[indexStack.peek()].first, indexStack.peek(), dy) + } + } + }) + } + } +}
\ No newline at end of file diff --git a/core/src/main/kotlin/ca/allanwang/kau/adapters/FastItemThemedAdapter.kt b/core/src/main/kotlin/ca/allanwang/kau/adapters/FastItemThemedAdapter.kt new file mode 100644 index 0000000..66fec4b --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/adapters/FastItemThemedAdapter.kt @@ -0,0 +1,189 @@ +package ca.allanwang.kau.adapters + +import android.content.res.ColorStateList +import android.view.View +import android.widget.ImageView +import android.widget.TextView +import ca.allanwang.kau.utils.adjustAlpha +import ca.allanwang.kau.views.createSimpleRippleDrawable +import com.mikepenz.fastadapter.IExpandable +import com.mikepenz.fastadapter.IItem +import com.mikepenz.fastadapter.ISubItem +import com.mikepenz.fastadapter.commons.adapters.FastItemAdapter + +/** + * Created by Allan Wang on 2017-06-29. + * + * Adapter with a set of colors that will be added to all subsequent items + * Changing a color while the adapter is not empty will reload all items + * + * This adapter overrides every method where an item is added + * If that item extends [ThemableIItem], then the colors will be set + */ +class FastItemThemedAdapter<Item : IItem<*, *>>( + textColor: Int? = null, + backgroundColor: Int? = null, + accentColor: Int? = null +) : FastItemAdapter<Item>() { + constructor(colors: ThemableIItemColors) : this(colors.textColor, colors.backgroundColor, colors.accentColor) + + var textColor: Int? = textColor + set(value) { + if (field == value) return + field = value + themeChanged() + } + var backgroundColor: Int? = backgroundColor + set(value) { + if (field == value) return + field = value + themeChanged() + } + var accentColor: Int? = accentColor + set(value) { + if (field == value) return + field = value + themeChanged() + } + + fun setColors(colors: ThemableIItemColors) { + this.textColor = colors.textColor + this.backgroundColor = colors.backgroundColor + this.accentColor = colors.accentColor + } + + fun themeChanged() { + if (adapterItemCount == 0) return + injectTheme(adapterItems) + notifyAdapterDataSetChanged() + } + + override fun add(position: Int, items: MutableList<Item>): FastItemAdapter<Item> { + injectTheme(items) + return super.add(position, items) + } + + override fun add(position: Int, item: Item): FastItemAdapter<Item> { + injectTheme(item) + return super.add(position, item) + } + + override fun add(item: Item): FastItemAdapter<Item> { + injectTheme(item) + return super.add(item) + } + + override fun add(items: MutableList<Item>): FastItemAdapter<Item> { + injectTheme(items) + injectTheme(items) + return super.add(items) + } + + override fun set(items: MutableList<Item>?): FastItemAdapter<Item> { + injectTheme(items) + return super.set(items) + } + + override fun set(position: Int, item: Item): FastItemAdapter<Item> { + injectTheme(item) + return super.set(position, item) + } + + override fun setNewList(items: MutableList<Item>?, retainFilter: Boolean): FastItemAdapter<Item> { + injectTheme(items) + return super.setNewList(items, retainFilter) + } + + override fun setNewList(items: MutableList<Item>?): FastItemAdapter<Item> { + injectTheme(items) + return super.setNewList(items) + } + + override fun <T, S> setSubItems(collapsible: T, subItems: MutableList<S>?): T where S : IItem<*, *>?, T : IItem<*, *>?, T : IExpandable<T, S>?, S : ISubItem<Item, T>? { + injectTheme(subItems) + return super.setSubItems(collapsible, subItems) + } + + internal fun injectTheme(items: Collection<IItem<*, *>?>?) { + items?.forEach { injectTheme(it) } + } + + internal fun injectTheme(item: IItem<*, *>?) { + if (item is ThemableIItem && item.themeEnabled) { + item.textColor = textColor + item.backgroundColor = backgroundColor + item.accentColor = accentColor + } + } +} + +interface ThemableIItemColors { + var textColor: Int? + var backgroundColor: Int? + var accentColor: Int? +} + +class ThemableIItemColorsDelegate : ThemableIItemColors { + override var textColor: Int? = null + override var backgroundColor: Int? = null + override var accentColor: Int? = null +} + +/** + * Interface that needs to be implemented by every iitem + * Holds the color values and has helper methods to inject the colors + */ +interface ThemableIItem : ThemableIItemColors { + var themeEnabled: Boolean + fun bindTextColor(vararg views: TextView?) + fun bindTextColorSecondary(vararg views: TextView?) + fun bindDividerColor(vararg views: View?) + fun bindAccentColor(vararg views: TextView?) + fun bindBackgroundColor(vararg views: View?) + fun bindBackgroundRipple(vararg views: View?) + fun bindIconColor(vararg views: ImageView?) +} + +/** + * The delegate for [ThemableIItem] + */ +class ThemableIItemDelegate : ThemableIItem, ThemableIItemColors by ThemableIItemColorsDelegate() { + override var themeEnabled: Boolean = true + + override fun bindTextColor(vararg views: TextView?) { + val color = textColor ?: return + views.forEach { it?.setTextColor(color) } + } + + override fun bindTextColorSecondary(vararg views: TextView?) { + val color = textColor?.adjustAlpha(0.8f) ?: return + views.forEach { it?.setTextColor(color) } + } + + override fun bindAccentColor(vararg views: TextView?) { + val color = accentColor ?: textColor ?: return + views.forEach { it?.setTextColor(color) } + } + + override fun bindDividerColor(vararg views: View?) { + val color = (textColor ?: accentColor)?.adjustAlpha(0.1f) ?: return + views.forEach { it?.setBackgroundColor(color) } + } + + override fun bindBackgroundColor(vararg views: View?) { + val color = backgroundColor ?: return + views.forEach { it?.setBackgroundColor(color) } + } + + override fun bindBackgroundRipple(vararg views: View?) { + val foreground = accentColor ?: textColor ?: return + val background = backgroundColor ?: return + val ripple = createSimpleRippleDrawable(foreground, background) + views.forEach { it?.background = ripple } + } + + override fun bindIconColor(vararg views: ImageView?) { + val color = accentColor ?: textColor ?: return + views.forEach { it?.drawable?.setTintList(ColorStateList.valueOf(color)) } + } +}
\ No newline at end of file diff --git a/core/src/main/kotlin/ca/allanwang/kau/adapters/SectionAdapter.kt b/core/src/main/kotlin/ca/allanwang/kau/adapters/SectionAdapter.kt new file mode 100644 index 0000000..cf7205a --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/adapters/SectionAdapter.kt @@ -0,0 +1,13 @@ +package ca.allanwang.kau.adapters + +import com.mikepenz.fastadapter.IItem +import com.mikepenz.fastadapter.adapters.HeaderAdapter + +/** + * Created by Allan Wang on 2017-06-27. + * + * Extension of [HeaderAdapter] where we can define the order + */ +class SectionAdapter<Item : IItem<*, *>>(var sectionOrder: Int = 100) : HeaderAdapter<Item>() { + override fun getOrder(): Int = sectionOrder +}
\ No newline at end of file diff --git a/core/src/main/kotlin/ca/allanwang/kau/animators/BaseDelayAnimator.kt b/core/src/main/kotlin/ca/allanwang/kau/animators/BaseDelayAnimator.kt new file mode 100644 index 0000000..c649376 --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/animators/BaseDelayAnimator.kt @@ -0,0 +1,45 @@ +package ca.allanwang.kau.animators + +import android.support.v7.widget.RecyclerView +import android.view.ViewPropertyAnimator + +/** + * Created by Allan Wang on 2017-06-27. + * + * Base for delayed animators + * item delay factor by default can be 0.125f + */ +abstract class BaseDelayAnimator(val itemDelayFactor: Float) : DefaultAnimator() { + + override abstract fun addAnimationPrepare(holder: RecyclerView.ViewHolder) + + override fun addAnimation(holder: RecyclerView.ViewHolder): ViewPropertyAnimator { + return holder.itemView.animate().apply { + startDelay = Math.max(0L, (holder.adapterPosition * addDuration * itemDelayFactor).toLong()) + duration = this@BaseDelayAnimator.addDuration + interpolator = this@BaseDelayAnimator.interpolator + } + } + + + override abstract fun addAnimationCleanup(holder: RecyclerView.ViewHolder) + + override fun getAddDelay(remove: Long, move: Long, change: Long): Long = 0 + + override fun getRemoveDelay(remove: Long, move: Long, change: Long): Long = 0 + + /** + * Partial removal animation + * As of now, all it does it change the alpha + * To have it slide, add onto it in a sub class + */ + override fun removeAnimation(holder: RecyclerView.ViewHolder): ViewPropertyAnimator { + return holder.itemView.animate().apply { + duration = this@BaseDelayAnimator.removeDuration + startDelay = Math.max(0L, (holder.adapterPosition * removeDuration * itemDelayFactor).toLong()) + interpolator = this@BaseDelayAnimator.interpolator + } + } + + override abstract fun removeAnimationCleanup(holder: RecyclerView.ViewHolder) +}
\ No newline at end of file diff --git a/core/src/main/kotlin/ca/allanwang/kau/animators/BaseItemAnimator.java b/core/src/main/kotlin/ca/allanwang/kau/animators/BaseItemAnimator.java new file mode 100644 index 0000000..69c2cf3 --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/animators/BaseItemAnimator.java @@ -0,0 +1,764 @@ +package ca.allanwang.kau.animators; + +/* + * Created by Allan Wang on 2017-06-27. + * + * Based on Item Animator by {@author Mike Penz} + * Rewritten to match with the updated compat dependencies + */ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * 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. + */ + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.TimeInterpolator; +import android.animation.ValueAnimator; +import android.support.annotation.NonNull; +import android.support.v4.view.ViewCompat; +import android.support.v7.widget.RecyclerView.ViewHolder; +import android.support.v7.widget.SimpleItemAnimator; +import android.view.View; +import android.view.ViewPropertyAnimator; +import android.view.animation.Interpolator; + +import java.util.ArrayList; +import java.util.List; + +/** + * This implementation of {@link android.support.v7.widget.RecyclerView.ItemAnimator} provides basic + * animations on remove, add, and move events that happen to the items in + * a RecyclerView. RecyclerView uses a DefaultItemAnimator by default. + * + * @see android.support.v7.widget.RecyclerView#setItemAnimator(android.support.v7.widget.RecyclerView.ItemAnimator) + */ +public abstract class BaseItemAnimator extends SimpleItemAnimator { + private static final boolean DEBUG = false; + + private static TimeInterpolator sDefaultInterpolator; + + private ArrayList<ViewHolder> mPendingRemovals = new ArrayList<>(); + private ArrayList<ViewHolder> mPendingAdditions = new ArrayList<>(); + private ArrayList<MoveInfo> mPendingMoves = new ArrayList<>(); + private ArrayList<ChangeInfo> mPendingChanges = new ArrayList<>(); + + ArrayList<ArrayList<ViewHolder>> mAdditionsList = new ArrayList<>(); + ArrayList<ArrayList<MoveInfo>> mMovesList = new ArrayList<>(); + ArrayList<ArrayList<ChangeInfo>> mChangesList = new ArrayList<>(); + + ArrayList<ViewHolder> mAddAnimations = new ArrayList<>(); + ArrayList<ViewHolder> mMoveAnimations = new ArrayList<>(); + ArrayList<ViewHolder> mRemoveAnimations = new ArrayList<>(); + ArrayList<ViewHolder> mChangeAnimations = new ArrayList<>(); + + public Interpolator interpolator; + + private static class MoveInfo { + public ViewHolder holder; + public int fromX, fromY, toX, toY; + + MoveInfo(ViewHolder holder, int fromX, int fromY, int toX, int toY) { + this.holder = holder; + this.fromX = fromX; + this.fromY = fromY; + this.toX = toX; + this.toY = toY; + } + } + + public static class ChangeInfo { + public ViewHolder oldHolder, newHolder; + public int fromX, fromY, toX, toY; + + private ChangeInfo(ViewHolder oldHolder, ViewHolder newHolder) { + this.oldHolder = oldHolder; + this.newHolder = newHolder; + } + + ChangeInfo(ViewHolder oldHolder, ViewHolder newHolder, + int fromX, int fromY, int toX, int toY) { + this(oldHolder, newHolder); + this.fromX = fromX; + this.fromY = fromY; + this.toX = toX; + this.toY = toY; + } + + @Override + public String toString() { + return "ChangeInfo{" + + "oldHolder=" + oldHolder + + ", newHolder=" + newHolder + + ", fromX=" + fromX + + ", fromY=" + fromY + + ", toX=" + toX + + ", toY=" + toY + + '}'; + } + } + + @Override + public void runPendingAnimations() { + boolean removalsPending = !mPendingRemovals.isEmpty(); + boolean movesPending = !mPendingMoves.isEmpty(); + boolean changesPending = !mPendingChanges.isEmpty(); + boolean additionsPending = !mPendingAdditions.isEmpty(); + if (!removalsPending && !movesPending && !additionsPending && !changesPending) { + // nothing to animate + return; + } + // First, remove stuff + for (ViewHolder holder : mPendingRemovals) { + animateRemoveImpl(holder); + } + mPendingRemovals.clear(); + // Next, move stuff + if (movesPending) { + final ArrayList<MoveInfo> moves = new ArrayList<>(); + moves.addAll(mPendingMoves); + mMovesList.add(moves); + mPendingMoves.clear(); + Runnable mover = new Runnable() { + @Override + public void run() { + for (MoveInfo moveInfo : moves) { + animateMoveImpl(moveInfo.holder, moveInfo.fromX, moveInfo.fromY, + moveInfo.toX, moveInfo.toY); + } + moves.clear(); + mMovesList.remove(moves); + } + }; + if (removalsPending) { + View view = moves.get(0).holder.itemView; + ViewCompat.postOnAnimationDelayed(view, mover, 0); + } else { + mover.run(); + } + } + // Next, change stuff, to run in parallel with move animations + if (changesPending) { + final ArrayList<ChangeInfo> changes = new ArrayList<>(); + changes.addAll(mPendingChanges); + mChangesList.add(changes); + mPendingChanges.clear(); + Runnable changer = new Runnable() { + @Override + public void run() { + for (ChangeInfo change : changes) { + animateChangeImpl(change); + } + changes.clear(); + mChangesList.remove(changes); + } + }; + if (removalsPending) { + ViewHolder holder = changes.get(0).oldHolder; + long moveDuration = movesPending ? getMoveDuration() : 0; + ViewCompat.postOnAnimationDelayed(holder.itemView, changer, getRemoveDelay(getRemoveDuration(), moveDuration, getChangeDuration())); + } else { + changer.run(); + } + } + // Next, add stuff + if (additionsPending) { + final ArrayList<ViewHolder> additions = new ArrayList<>(); + additions.addAll(mPendingAdditions); + mAdditionsList.add(additions); + mPendingAdditions.clear(); + Runnable adder = new Runnable() { + @Override + public void run() { + for (ViewHolder holder : additions) { + animateAddImpl(holder); + } + additions.clear(); + mAdditionsList.remove(additions); + } + }; + if (removalsPending || movesPending || changesPending) { + long removeDuration = removalsPending ? getRemoveDuration() : 0; + long moveDuration = movesPending ? getMoveDuration() : 0; + long changeDuration = changesPending ? getChangeDuration() : 0; + View view = additions.get(0).itemView; + ViewCompat.postOnAnimationDelayed(view, adder, getAddDelay(removeDuration, moveDuration, changeDuration)); + } else { + adder.run(); + } + } + } + + /** + * used to calculated the delay until the remove animation should start + * + * @param remove the remove duration + * @param move the move duration + * @param change the change duration + * @return the calculated delay for the remove items animation + */ + public long getRemoveDelay(long remove, long move, long change) { + return remove + Math.max(move, change); + } + + /** + * used to calculated the delay until the add animation should start + * + * @param remove the remove duration + * @param move the move duration + * @param change the change duration + * @return the calculated delay for the add items animation + */ + public long getAddDelay(long remove, long move, long change) { + return remove + Math.max(move, change); + } + + @Override + public boolean animateRemove(final ViewHolder holder) { + resetAnimation(holder); + mPendingRemovals.add(holder); + return true; + } + + private void animateRemoveImpl(final ViewHolder holder) { + final ViewPropertyAnimator animation = removeAnimation(holder); + mRemoveAnimations.add(holder); + animation.setListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(Animator animator) { + dispatchRemoveStarting(holder); + } + + @Override + public void onAnimationEnd(Animator animator) { + animation.setListener(null); + removeAnimationCleanup(holder); + dispatchRemoveFinished(holder); + mRemoveAnimations.remove(holder); + dispatchFinishedWhenDone(); + } + }).start(); + } + + abstract public ViewPropertyAnimator removeAnimation(ViewHolder holder); + + abstract public void removeAnimationCleanup(ViewHolder holder); + + @Override + public boolean animateAdd(final ViewHolder holder) { + resetAnimation(holder); + addAnimationPrepare(holder); + mPendingAdditions.add(holder); + return true; + } + + void animateAddImpl(final ViewHolder holder) { + final ViewPropertyAnimator animation = addAnimation(holder); + mAddAnimations.add(holder); + animation.setListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(Animator animator) { + dispatchAddStarting(holder); + } + + @Override + public void onAnimationCancel(Animator animator) { + addAnimationCleanup(holder); + } + + @Override + public void onAnimationEnd(Animator animator) { + animation.setListener(null); + dispatchAddFinished(holder); + mAddAnimations.remove(holder); + dispatchFinishedWhenDone(); + addAnimationCleanup(holder); + } + }).start(); + } + + /** + * the animation to prepare the view before the add animation is run + * + * @param holder + */ + public abstract void addAnimationPrepare(ViewHolder holder); + + /** + * the animation for adding a view + * + * @param holder + * @return + */ + public abstract ViewPropertyAnimator addAnimation(ViewHolder holder); + + /** + * the cleanup method if the animation needs to be stopped. and tro prepare for the next view + * + * @param holder + */ + abstract void addAnimationCleanup(ViewHolder holder); + + @Override + public boolean animateMove(final ViewHolder holder, int fromX, int fromY, + int toX, int toY) { + final View view = holder.itemView; + fromX += (int) holder.itemView.getTranslationX(); + fromY += (int) holder.itemView.getTranslationY(); + resetAnimation(holder); + int deltaX = toX - fromX; + int deltaY = toY - fromY; + if (deltaX == 0 && deltaY == 0) { + dispatchMoveFinished(holder); + return false; + } + if (deltaX != 0) { + view.setTranslationX(-deltaX); + } + if (deltaY != 0) { + view.setTranslationY(-deltaY); + } + mPendingMoves.add(new MoveInfo(holder, fromX, fromY, toX, toY)); + return true; + } + + void animateMoveImpl(final ViewHolder holder, int fromX, int fromY, int toX, int toY) { + final View view = holder.itemView; + final int deltaX = toX - fromX; + final int deltaY = toY - fromY; + if (deltaX != 0) { + view.animate().translationX(0); + } + if (deltaY != 0) { + view.animate().translationY(0); + } + // TODO: make EndActions end listeners instead, since end actions aren't called when + // vpas are canceled (and can't end them. why?) + // need listener functionality in VPACompat for this. Ick. + final ViewPropertyAnimator animation = view.animate(); + mMoveAnimations.add(holder); + animation.setDuration(getMoveDuration()).setListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(Animator animator) { + dispatchMoveStarting(holder); + } + + @Override + public void onAnimationCancel(Animator animator) { + if (deltaX != 0) { + view.setTranslationX(0); + } + if (deltaY != 0) { + view.setTranslationY(0); + } + } + + @Override + public void onAnimationEnd(Animator animator) { + animation.setListener(null); + dispatchMoveFinished(holder); + mMoveAnimations.remove(holder); + dispatchFinishedWhenDone(); + } + }).start(); + } + + @Override + public boolean animateChange(ViewHolder oldHolder, ViewHolder newHolder, + int fromX, int fromY, int toX, int toY) { + if (oldHolder == newHolder) { + // Don't know how to run change animations when the same view holder is re-used. + // run a move animation to handle position changes. + return animateMove(oldHolder, fromX, fromY, toX, toY); + } + changeAnimation(oldHolder, newHolder, + fromX, fromY, toX, toY); + mPendingChanges.add(new ChangeInfo(oldHolder, newHolder, fromX, fromY, toX, toY)); + return true; + } + + void animateChangeImpl(final ChangeInfo changeInfo) { + final ViewHolder holder = changeInfo.oldHolder; + final View view = holder == null ? null : holder.itemView; + final ViewHolder newHolder = changeInfo.newHolder; + final View newView = newHolder != null ? newHolder.itemView : null; + if (view != null) { + final ViewPropertyAnimator oldViewAnim = changeOldAnimation(holder, changeInfo); + mChangeAnimations.add(changeInfo.oldHolder); + oldViewAnim.setListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(Animator animator) { + dispatchChangeStarting(changeInfo.oldHolder, true); + } + + @Override + public void onAnimationEnd(Animator animator) { + oldViewAnim.setListener(null); + changeAnimationCleanup(holder); + view.setTranslationX(0); + view.setTranslationY(0); + dispatchChangeFinished(changeInfo.oldHolder, true); + mChangeAnimations.remove(changeInfo.oldHolder); + dispatchFinishedWhenDone(); + } + }).start(); + } + if (newView != null) { + final ViewPropertyAnimator newViewAnimation = changeNewAnimation(newHolder); + mChangeAnimations.add(changeInfo.newHolder); + newViewAnimation.setListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(Animator animator) { + dispatchChangeStarting(changeInfo.newHolder, false); + } + + @Override + public void onAnimationEnd(Animator animator) { + newViewAnimation.setListener(null); + changeAnimationCleanup(newHolder); + newView.setTranslationX(0); + newView.setTranslationY(0); + dispatchChangeFinished(changeInfo.newHolder, false); + mChangeAnimations.remove(changeInfo.newHolder); + dispatchFinishedWhenDone(); + } + }).start(); + } + } + + /** + * the whole change animation if we have to cross animate two views + * + * @param oldHolder + * @param newHolder + * @param fromX + * @param fromY + * @param toX + * @param toY + */ + public void changeAnimation(ViewHolder oldHolder, ViewHolder newHolder, int fromX, int fromY, int toX, int toY) { + final float prevTranslationX = oldHolder.itemView.getTranslationX(); + final float prevTranslationY = oldHolder.itemView.getTranslationY(); + final float prevValue = oldHolder.itemView.getAlpha(); + resetAnimation(oldHolder); + int deltaX = (int) (toX - fromX - prevTranslationX); + int deltaY = (int) (toY - fromY - prevTranslationY); + // recover prev translation state after ending animation + oldHolder.itemView.setTranslationX(prevTranslationX); + oldHolder.itemView.setTranslationY(prevTranslationY); + + oldHolder.itemView.setAlpha(prevValue); + if (newHolder != null) { + // carry over translation values + resetAnimation(newHolder); + newHolder.itemView.setTranslationX(-deltaX); + newHolder.itemView.setTranslationY(-deltaY); + newHolder.itemView.setAlpha(0); + } + } + + /** + * the animation for removing the old view + * + * @param holder + * @return + */ + public abstract ViewPropertyAnimator changeOldAnimation(ViewHolder holder, ChangeInfo changeInfo); + + /** + * the animation for changing the new view + * + * @param holder + * @return + */ + public abstract ViewPropertyAnimator changeNewAnimation(ViewHolder holder); + + /** + * the cleanup method if the animation needs to be stopped. and tro prepare for the next view + * + * @param holder + */ + public abstract void changeAnimationCleanup(ViewHolder holder); + + private void endChangeAnimation(List<ChangeInfo> infoList, ViewHolder item) { + for (int i = infoList.size() - 1; i >= 0; i--) { + ChangeInfo changeInfo = infoList.get(i); + if (endChangeAnimationIfNecessary(changeInfo, item)) { + if (changeInfo.oldHolder == null && changeInfo.newHolder == null) { + infoList.remove(changeInfo); + } + } + } + } + + private void endChangeAnimationIfNecessary(ChangeInfo changeInfo) { + if (changeInfo.oldHolder != null) { + endChangeAnimationIfNecessary(changeInfo, changeInfo.oldHolder); + } + if (changeInfo.newHolder != null) { + endChangeAnimationIfNecessary(changeInfo, changeInfo.newHolder); + } + } + + private boolean endChangeAnimationIfNecessary(ChangeInfo changeInfo, ViewHolder item) { + boolean oldItem = false; + if (changeInfo.newHolder == item) { + changeInfo.newHolder = null; + } else if (changeInfo.oldHolder == item) { + changeInfo.oldHolder = null; + oldItem = true; + } else { + return false; + } + changeAnimationCleanup(item); + item.itemView.setTranslationX(0); + item.itemView.setTranslationY(0); + dispatchChangeFinished(item, oldItem); + return true; + } + + @Override + public void endAnimation(ViewHolder item) { + final View view = item.itemView; + // this will trigger end callback which should set properties to their target values. + view.animate().cancel(); + // TODO if some other animations are chained to end, how do we cancel them as well? + for (int i = mPendingMoves.size() - 1; i >= 0; i--) { + MoveInfo moveInfo = mPendingMoves.get(i); + if (moveInfo.holder == item) { + view.setTranslationY(0); + view.setTranslationX(0); + dispatchMoveFinished(item); + mPendingMoves.remove(i); + } + } + endChangeAnimation(mPendingChanges, item); + if (mPendingRemovals.remove(item)) { + removeAnimationCleanup(item); + dispatchRemoveFinished(item); + } + if (mPendingAdditions.remove(item)) { + addAnimationCleanup(item); + dispatchAddFinished(item); + } + + for (int i = mChangesList.size() - 1; i >= 0; i--) { + ArrayList<ChangeInfo> changes = mChangesList.get(i); + endChangeAnimation(changes, item); + if (changes.isEmpty()) { + mChangesList.remove(i); + } + } + for (int i = mMovesList.size() - 1; i >= 0; i--) { + ArrayList<MoveInfo> moves = mMovesList.get(i); + for (int j = moves.size() - 1; j >= 0; j--) { + MoveInfo moveInfo = moves.get(j); + if (moveInfo.holder == item) { + view.setTranslationY(0); + view.setTranslationX(0); + dispatchMoveFinished(item); + moves.remove(j); + if (moves.isEmpty()) { + mMovesList.remove(i); + } + break; + } + } + } + for (int i = mAdditionsList.size() - 1; i >= 0; i--) { + ArrayList<ViewHolder> additions = mAdditionsList.get(i); + if (additions.remove(item)) { + addAnimationCleanup(item); + dispatchAddFinished(item); + if (additions.isEmpty()) { + mAdditionsList.remove(i); + } + } + } + + // animations should be ended by the cancel above. + //noinspection PointlessBooleanExpression,ConstantConditions + if (mRemoveAnimations.remove(item) && DEBUG) { + throw new IllegalStateException("after animation is cancelled, item should not be in " + + "mRemoveAnimations list"); + } + + //noinspection PointlessBooleanExpression,ConstantConditions + if (mAddAnimations.remove(item) && DEBUG) { + throw new IllegalStateException("after animation is cancelled, item should not be in " + + "mAddAnimations list"); + } + + //noinspection PointlessBooleanExpression,ConstantConditions + if (mChangeAnimations.remove(item) && DEBUG) { + throw new IllegalStateException("after animation is cancelled, item should not be in " + + "mChangeAnimations list"); + } + + //noinspection PointlessBooleanExpression,ConstantConditions + if (mMoveAnimations.remove(item) && DEBUG) { + throw new IllegalStateException("after animation is cancelled, item should not be in " + + "mMoveAnimations list"); + } + dispatchFinishedWhenDone(); + } + + private void resetAnimation(ViewHolder holder) { + + if (sDefaultInterpolator == null) { + sDefaultInterpolator = new ValueAnimator().getInterpolator(); + } + holder.itemView.animate().setInterpolator(sDefaultInterpolator); + endAnimation(holder); + } + + @Override + public boolean isRunning() { + return (!mPendingAdditions.isEmpty() + || !mPendingChanges.isEmpty() + || !mPendingMoves.isEmpty() + || !mPendingRemovals.isEmpty() + || !mMoveAnimations.isEmpty() + || !mRemoveAnimations.isEmpty() + || !mAddAnimations.isEmpty() + || !mChangeAnimations.isEmpty() + || !mMovesList.isEmpty() + || !mAdditionsList.isEmpty() + || !mChangesList.isEmpty()); + } + + /** + * Check the state of currently pending and running animations. If there are none + * pending/running, call {@link #dispatchAnimationsFinished()} to notify any + * listeners. + */ + void dispatchFinishedWhenDone() { + if (!isRunning()) { + dispatchAnimationsFinished(); + } + } + + @Override + public void endAnimations() { + int count = mPendingMoves.size(); + for (int i = count - 1; i >= 0; i--) { + MoveInfo item = mPendingMoves.get(i); + View view = item.holder.itemView; + view.setTranslationY(0); + view.setTranslationX(0); + dispatchMoveFinished(item.holder); + mPendingMoves.remove(i); + } + count = mPendingRemovals.size(); + for (int i = count - 1; i >= 0; i--) { + ViewHolder item = mPendingRemovals.get(i); + dispatchRemoveFinished(item); + mPendingRemovals.remove(i); + } + count = mPendingAdditions.size(); + for (int i = count - 1; i >= 0; i--) { + ViewHolder item = mPendingAdditions.get(i); + addAnimationCleanup(item); + dispatchAddFinished(item); + mPendingAdditions.remove(i); + } + count = mPendingChanges.size(); + for (int i = count - 1; i >= 0; i--) { + endChangeAnimationIfNecessary(mPendingChanges.get(i)); + } + mPendingChanges.clear(); + if (!isRunning()) { + return; + } + + int listCount = mMovesList.size(); + for (int i = listCount - 1; i >= 0; i--) { + ArrayList<MoveInfo> moves = mMovesList.get(i); + count = moves.size(); + for (int j = count - 1; j >= 0; j--) { + MoveInfo moveInfo = moves.get(j); + ViewHolder item = moveInfo.holder; + View view = item.itemView; + view.setTranslationY(0); + view.setTranslationX(0); + dispatchMoveFinished(moveInfo.holder); + moves.remove(j); + if (moves.isEmpty()) { + mMovesList.remove(moves); + } + } + } + listCount = mAdditionsList.size(); + for (int i = listCount - 1; i >= 0; i--) { + ArrayList<ViewHolder> additions = mAdditionsList.get(i); + count = additions.size(); + for (int j = count - 1; j >= 0; j--) { + ViewHolder item = additions.get(j); + View view = item.itemView; + addAnimationCleanup(item); + dispatchAddFinished(item); + additions.remove(j); + if (additions.isEmpty()) { + mAdditionsList.remove(additions); + } + } + } + listCount = mChangesList.size(); + for (int i = listCount - 1; i >= 0; i--) { + ArrayList<ChangeInfo> changes = mChangesList.get(i); + count = changes.size(); + for (int j = count - 1; j >= 0; j--) { + endChangeAnimationIfNecessary(changes.get(j)); + if (changes.isEmpty()) { + mChangesList.remove(changes); + } + } + } + + cancelAll(mRemoveAnimations); + cancelAll(mMoveAnimations); + cancelAll(mAddAnimations); + cancelAll(mChangeAnimations); + + dispatchAnimationsFinished(); + } + + void cancelAll(List<ViewHolder> viewHolders) { + for (int i = viewHolders.size() - 1; i >= 0; i--) { + viewHolders.get(i).itemView.animate().cancel(); + } + } + + /** + * {@inheritDoc} + * <p> + * If the payload list is not empty, DefaultItemAnimator returns <code>true</code>. + * When this is the case: + * <ul> + * <li>If you override {@link #animateChange(ViewHolder, ViewHolder, int, int, int, int)}, both + * ViewHolder arguments will be the same instance. + * </li> + * <li> + * If you are not overriding {@link #animateChange(ViewHolder, ViewHolder, int, int, int, int)}, + * then DefaultItemAnimator will call {@link #animateMove(ViewHolder, int, int, int, int)} and + * run a move animation instead. + * </li> + * </ul> + */ + @Override + public boolean canReuseUpdatedViewHolder(@NonNull ViewHolder viewHolder, + @NonNull List<Object> payloads) { + return !payloads.isEmpty() || super.canReuseUpdatedViewHolder(viewHolder, payloads); + } +} diff --git a/core/src/main/kotlin/ca/allanwang/kau/animators/BaseSlideAlphaAnimator.kt b/core/src/main/kotlin/ca/allanwang/kau/animators/BaseSlideAlphaAnimator.kt new file mode 100644 index 0000000..a963358 --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/animators/BaseSlideAlphaAnimator.kt @@ -0,0 +1,52 @@ +package ca.allanwang.kau.animators + +import android.support.v7.widget.RecyclerView +import android.view.ViewPropertyAnimator + +/** + * Created by Allan Wang on 2017-06-27. + * + * Base for sliding animators + * item delay factor by default can be 0.125f + */ +abstract class BaseSlideAlphaAnimator(itemDelayFactor: Float) : BaseDelayAnimator(itemDelayFactor) { + + override fun addAnimation(holder: RecyclerView.ViewHolder): ViewPropertyAnimator { + return super.addAnimation(holder).apply { + translationY(0f) + translationX(0f) + alpha(1f) + } + } + + final override fun addAnimationCleanup(holder: RecyclerView.ViewHolder) { + with(holder.itemView) { + translationY = 0f + translationX = 0f + alpha = 1f + } + } + + override fun getAddDelay(remove: Long, move: Long, change: Long): Long = 0 + + override fun getRemoveDelay(remove: Long, move: Long, change: Long): Long = 0 + + /** + * Partial removal animation + * As of now, all it does it change the alpha + * To have it slide, add onto it in a sub class + */ + override fun removeAnimation(holder: RecyclerView.ViewHolder): ViewPropertyAnimator { + return super.addAnimation(holder).apply { + alpha(0f) + } + } + + override final fun removeAnimationCleanup(holder: RecyclerView.ViewHolder) { + with(holder.itemView) { + translationY = 0f + translationX = 0f + alpha = 1f + } + } +}
\ No newline at end of file diff --git a/core/src/main/kotlin/ca/allanwang/kau/animators/DefaultAnimator.kt b/core/src/main/kotlin/ca/allanwang/kau/animators/DefaultAnimator.kt new file mode 100644 index 0000000..9aeafde --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/animators/DefaultAnimator.kt @@ -0,0 +1,63 @@ +package ca.allanwang.kau.animators + +import android.support.v7.widget.RecyclerView +import android.view.ViewPropertyAnimator + +/** + * Created by Allan Wang on 2017-06-27. + */ +open class DefaultAnimator : BaseItemAnimator() { + + override fun removeAnimation(holder: RecyclerView.ViewHolder): ViewPropertyAnimator { + return holder.itemView.animate().apply { + alpha(0f) + duration = this@DefaultAnimator.removeDuration + interpolator = this@DefaultAnimator.interpolator + } + } + + override fun removeAnimationCleanup(holder: RecyclerView.ViewHolder) { + holder.itemView.alpha = 1f + } + + override fun addAnimationPrepare(holder: RecyclerView.ViewHolder) { + holder.itemView.alpha = 0f + } + + override fun addAnimation(holder: RecyclerView.ViewHolder): ViewPropertyAnimator { + return holder.itemView.animate().apply { + alpha(1f) + duration = this@DefaultAnimator.addDuration + interpolator = this@DefaultAnimator.interpolator + } + } + + override fun addAnimationCleanup(holder: RecyclerView.ViewHolder) { + holder.itemView.alpha = 1f + } + + override fun changeOldAnimation(holder: RecyclerView.ViewHolder, changeInfo: ChangeInfo): ViewPropertyAnimator { + return holder.itemView.animate().apply { + alpha(0f) + translationX(changeInfo.toX.toFloat() - changeInfo.fromX) + translationY(changeInfo.toY.toFloat() - changeInfo.fromY) + duration = this@DefaultAnimator.changeDuration + interpolator = this@DefaultAnimator.interpolator + } + } + + override fun changeNewAnimation(holder: RecyclerView.ViewHolder): ViewPropertyAnimator { + return holder.itemView.animate().apply { + alpha(1f) + translationX(0f) + translationY(0f) + duration = this@DefaultAnimator.changeDuration + interpolator = this@DefaultAnimator.interpolator + } + } + + override fun changeAnimationCleanup(holder: RecyclerView.ViewHolder) { + holder.itemView.alpha = 1f + } + +}
\ No newline at end of file diff --git a/core/src/main/kotlin/ca/allanwang/kau/animators/FadeScaleAnimator.kt b/core/src/main/kotlin/ca/allanwang/kau/animators/FadeScaleAnimator.kt new file mode 100644 index 0000000..e968cda --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/animators/FadeScaleAnimator.kt @@ -0,0 +1,51 @@ +package ca.allanwang.kau.animators + +import android.support.v7.widget.RecyclerView +import android.view.ViewPropertyAnimator + +/** + * Created by Allan Wang on 2017-06-29. + */ +open class FadeScaleAnimator(val scaleFactor: Float = 0.7f, itemDelayFactor: Float = 0.125f) : BaseDelayAnimator(itemDelayFactor) { + + override fun addAnimationPrepare(holder: RecyclerView.ViewHolder) { + with(holder.itemView) { + scaleX = scaleFactor + scaleY = scaleFactor + alpha = 0f + } + } + + override final fun addAnimation(holder: RecyclerView.ViewHolder): ViewPropertyAnimator { + return super.addAnimation(holder).apply { + scaleX(1f) + scaleY(1f) + alpha(1f) + } + } + + + final override fun addAnimationCleanup(holder: RecyclerView.ViewHolder) { + with(holder.itemView) { + scaleX = 1f + scaleY = 1f + alpha = 1f + } + } + + override fun removeAnimation(holder: RecyclerView.ViewHolder): ViewPropertyAnimator { + return super.removeAnimation(holder).apply { + scaleX(scaleFactor) + scaleY(scaleFactor) + alpha(0f) + } + } + + override final fun removeAnimationCleanup(holder: RecyclerView.ViewHolder) { + with(holder.itemView) { + translationY = 0f + translationX = 0f + alpha = 1f + } + } +}
\ No newline at end of file diff --git a/core/src/main/kotlin/ca/allanwang/kau/animators/NoAnimator.kt b/core/src/main/kotlin/ca/allanwang/kau/animators/NoAnimator.kt new file mode 100644 index 0000000..244287b --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/animators/NoAnimator.kt @@ -0,0 +1,41 @@ +package ca.allanwang.kau.animators + +import android.support.v7.widget.RecyclerView +import android.view.ViewPropertyAnimator + +/** + * Created by Allan Wang on 2017-06-27. + * + * Truly have no animation + */ +class NoAnimator : DefaultAnimator() { + override fun addAnimationPrepare(holder: RecyclerView.ViewHolder) {} + + override fun addAnimation(holder: RecyclerView.ViewHolder): ViewPropertyAnimator = holder.itemView.animate().apply { duration = 0 } + + override fun addAnimationCleanup(holder: RecyclerView.ViewHolder) {} + + override fun changeOldAnimation(holder: RecyclerView.ViewHolder, changeInfo: ChangeInfo): ViewPropertyAnimator = holder.itemView.animate().apply { duration = 0 } + + override fun changeNewAnimation(holder: RecyclerView.ViewHolder): ViewPropertyAnimator = holder.itemView.animate().apply { duration = 0 } + + override fun changeAnimationCleanup(holder: RecyclerView.ViewHolder) {} + + override fun changeAnimation(oldHolder: RecyclerView.ViewHolder, newHolder: RecyclerView.ViewHolder?, fromX: Int, fromY: Int, toX: Int, toY: Int) {} + + override fun getAddDelay(remove: Long, move: Long, change: Long): Long = 0 + + override fun getAddDuration(): Long = 0 + + override fun getMoveDuration(): Long = 0 + + override fun getRemoveDuration(): Long = 0 + + override fun getChangeDuration(): Long = 0 + + override fun getRemoveDelay(remove: Long, move: Long, change: Long): Long = 0 + + override fun removeAnimation(holder: RecyclerView.ViewHolder): ViewPropertyAnimator = holder.itemView.animate().apply { duration = 0 } + + override fun removeAnimationCleanup(holder: RecyclerView.ViewHolder) {} +}
\ No newline at end of file diff --git a/core/src/main/kotlin/ca/allanwang/kau/animators/SlideUpExitRightAnimator.kt b/core/src/main/kotlin/ca/allanwang/kau/animators/SlideUpExitRightAnimator.kt new file mode 100644 index 0000000..8670493 --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/animators/SlideUpExitRightAnimator.kt @@ -0,0 +1,23 @@ +package ca.allanwang.kau.animators + +import android.support.v7.widget.RecyclerView +import android.view.ViewPropertyAnimator + +/** + * Created by Allan Wang on 2017-06-27. + */ +class SlideUpExitRightAnimator(itemDelayFactor: Float = 0.125f) : BaseSlideAlphaAnimator(itemDelayFactor) { + + override fun addAnimationPrepare(holder: RecyclerView.ViewHolder) { + with(holder.itemView) { + translationY = height.toFloat() + alpha = 0f + } + } + + override fun removeAnimation(holder: RecyclerView.ViewHolder): ViewPropertyAnimator { + return super.removeAnimation(holder).apply { + translationX(holder.itemView.width.toFloat()) + } + } +}
\ No newline at end of file diff --git a/core/src/main/kotlin/ca/allanwang/kau/changelog/Changelog.kt b/core/src/main/kotlin/ca/allanwang/kau/changelog/Changelog.kt new file mode 100644 index 0000000..302d9dc --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/changelog/Changelog.kt @@ -0,0 +1,105 @@ +package ca.allanwang.kau.changelog + +import android.content.Context +import android.content.res.XmlResourceParser +import android.os.Handler +import android.support.annotation.ColorInt +import android.support.annotation.LayoutRes +import android.support.annotation.XmlRes +import android.support.v7.widget.RecyclerView +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import ca.allanwang.kau.R +import ca.allanwang.kau.utils.bindOptionalView +import ca.allanwang.kau.utils.bindView +import ca.allanwang.kau.utils.materialDialog +import ca.allanwang.kau.utils.use +import com.afollestad.materialdialogs.MaterialDialog +import org.jetbrains.anko.doAsync +import org.jetbrains.anko.uiThread +import org.xmlpull.v1.XmlPullParser +import java.util.* + + +/** + * Created by Allan Wang on 2017-05-28. + */ + +fun Context.showChangelog(@XmlRes xmlRes: Int, @ColorInt textColor: Int? = null, customize: MaterialDialog.Builder.() -> Unit = {}) { + doAsync { + val items = parse(this@showChangelog, xmlRes) + uiThread { + materialDialog { + title(R.string.kau_changelog) + positiveText(R.string.kau_great) + adapter(ChangelogAdapter(items, textColor), null) + customize() + } + } + } +} + +/** + * Internals of the changelog dialog + * Contains an mainAdapter for each item, as well as the tags to parse + */ +internal class ChangelogAdapter(val items: List<Pair<String, ChangelogType>>, @ColorInt val textColor: Int? = null) : RecyclerView.Adapter<ChangelogAdapter.ChangelogVH>() { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ChangelogVH(LayoutInflater.from(parent.context) + .inflate(items[viewType].second.layout, parent, false)) + + override fun onBindViewHolder(holder: ChangelogVH, position: Int) { + holder.text.text = items[position].first + if (textColor != null) { + holder.text.setTextColor(textColor) + holder.bullet?.setTextColor(textColor) + } + } + + override fun getItemId(position: Int) = position.toLong() + + override fun getItemViewType(position: Int) = position + + override fun getItemCount() = items.size + + internal class ChangelogVH(itemView: View) : RecyclerView.ViewHolder(itemView) { + val text: TextView by bindView(R.id.kau_changelog_text) + val bullet: TextView? by bindOptionalView(R.id.kau_changelog_bullet) + } +} + +internal fun parse(context: Context, @XmlRes xmlRes: Int): List<Pair<String, ChangelogType>> { + val items = mutableListOf<Pair<String, ChangelogType>>() + context.resources.getXml(xmlRes).use { + parser: XmlResourceParser -> + var eventType = parser.eventType + while (eventType != XmlPullParser.END_DOCUMENT) { + if (eventType == XmlPullParser.START_TAG) + ChangelogType.values.any { it.add(parser, items) } + eventType = parser.next() + } + } + return items +} + +internal enum class ChangelogType(val tag: String, val attr: String, @LayoutRes val layout: Int) { + TITLE("version", "title", R.layout.kau_changelog_title), + ITEM("item", "text", R.layout.kau_changelog_content); + + companion object { + @JvmStatic val values = values() + } + + /** + * Returns true if tag matches; false otherwise + */ + fun add(parser: XmlResourceParser, list: MutableList<Pair<String, ChangelogType>>): Boolean { + if (parser.name != tag) return false + if (parser.getAttributeValue(null, attr).isNotBlank()) + list.add(Pair(parser.getAttributeValue(null, attr), this)) + return true + } +} + diff --git a/core/src/main/kotlin/ca/allanwang/kau/dialogs/color/CircleView.kt b/core/src/main/kotlin/ca/allanwang/kau/dialogs/color/CircleView.kt new file mode 100644 index 0000000..3430b42 --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/dialogs/color/CircleView.kt @@ -0,0 +1,228 @@ +package ca.allanwang.kau.dialogs.color + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.ValueAnimator +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Rect +import android.graphics.drawable.Drawable +import android.graphics.drawable.RippleDrawable +import android.graphics.drawable.ShapeDrawable +import android.graphics.drawable.StateListDrawable +import android.graphics.drawable.shapes.OvalShape +import android.os.Build +import android.support.annotation.ColorInt +import android.support.annotation.ColorRes +import android.support.annotation.FloatRange +import android.support.v4.view.GravityCompat +import android.support.v4.view.ViewCompat +import android.util.AttributeSet +import android.view.Gravity +import android.widget.FrameLayout +import android.widget.Toast +import ca.allanwang.kau.utils.color +import ca.allanwang.kau.utils.getDip +import ca.allanwang.kau.utils.toColor +import ca.allanwang.kau.utils.toHSV + +/** + * Created by Allan Wang on 2017-06-10. + * + * An extension of MaterialDialog's CircleView with animation selection + * [https://github.com/afollestad/material-dialogs/blob/master/commons/src/main/java/com/afollestad/materialdialogs/color/CircleView.java] + */ +class CircleView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : FrameLayout(context, attrs, defStyleAttr) { + + private val borderWidthMicro: Float = context.getDip(1f) + private val borderWidthSmall: Float = context.getDip(3f) + private val borderWidthLarge: Float = context.getDip(5f) + private var whiteOuterBound: Float = borderWidthLarge + + private val outerPaint: Paint = Paint().apply { isAntiAlias = true } + private val whitePaint: Paint = Paint().apply { isAntiAlias = true; color = Color.WHITE } + private val innerPaint: Paint = Paint().apply { isAntiAlias = true } + private var selected: Boolean = false + var withBorder: Boolean = false + get() = field + set(value) { + if (field != value) { + field = value + invalidate() + } + } + + init { + update(Color.DKGRAY) + setWillNotDraw(false) + } + + private fun update(@ColorInt color: Int) { + innerPaint.color = color + outerPaint.color = shiftColorDown(color) + + val selector = createSelector(color) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + val states = arrayOf(intArrayOf(android.R.attr.state_pressed)) + val colors = intArrayOf(shiftColorUp(color)) + val rippleColors = ColorStateList(states, colors) + foreground = RippleDrawable(rippleColors, selector, null) + } else { + foreground = selector + } + } + + override fun setBackgroundColor(@ColorInt color: Int) { + update(color) + requestLayout() + invalidate() + } + + override fun setBackgroundResource(@ColorRes color: Int) { + setBackgroundColor(context.color(color)) + } + + + @Deprecated("") + override fun setBackground(background: Drawable) { + throw IllegalStateException("Cannot use setBackground() on CircleView.") + } + + + @Deprecated("") + override fun setBackgroundDrawable(background: Drawable) { + throw IllegalStateException("Cannot use setBackgroundDrawable() on CircleView.") + } + + + @Deprecated("") + override fun setActivated(activated: Boolean) { + throw IllegalStateException("Cannot use setActivated() on CircleView.") + } + + override fun setSelected(selected: Boolean) { + this.selected = selected + whiteOuterBound = borderWidthLarge + invalidate() + } + + fun animateSelected(selected: Boolean) { + if (this.selected == selected) return + this.selected = true // We need to draw the other bands + val range = if (selected) Pair(-borderWidthSmall, borderWidthLarge) else Pair(borderWidthLarge, -borderWidthSmall) + val animator = ValueAnimator.ofFloat(range.first, range.second) + with(animator) { + reverse() + duration = 150L + addUpdateListener { animation -> + whiteOuterBound = animation.animatedValue as Float + invalidate() + } + addListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + this@CircleView.selected = selected + } + + override fun onAnimationCancel(animation: Animator) { + this@CircleView.selected = selected + } + }) + start() + } + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, widthMeasureSpec) + setMeasuredDimension(measuredWidth, measuredWidth) + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + val centerWidth = (measuredWidth / 2).toFloat() + val centerHeight = (measuredHeight / 2).toFloat() + if (withBorder) canvas.drawCircle(centerWidth, centerHeight, centerWidth, whitePaint) + if (selected) { + val whiteRadius = centerWidth - whiteOuterBound + val innerRadius = whiteRadius - borderWidthSmall + if (whiteRadius >= centerWidth) { + canvas.drawCircle(centerWidth, centerHeight, centerWidth, whitePaint) + } else { + canvas.drawCircle(centerWidth, centerHeight, if (withBorder) centerWidth - borderWidthMicro else centerWidth, outerPaint) + canvas.drawCircle(centerWidth, centerHeight, whiteRadius, whitePaint) + } + canvas.drawCircle(centerWidth, centerHeight, innerRadius, innerPaint) + } else { + canvas.drawCircle(centerWidth, centerHeight, if (withBorder) centerWidth - borderWidthMicro else centerWidth, innerPaint) + } + } + + private fun createSelector(color: Int): Drawable { + val darkerCircle = ShapeDrawable(OvalShape()) + darkerCircle.paint.color = translucentColor(shiftColorUp(color)) + val stateListDrawable = StateListDrawable() + stateListDrawable.addState(intArrayOf(android.R.attr.state_pressed), darkerCircle) + return stateListDrawable + } + + fun showHint(color: Int) { + val screenPos = IntArray(2) + val displayFrame = Rect() + getLocationOnScreen(screenPos) + getWindowVisibleDisplayFrame(displayFrame) + val context = context + val width = width + val height = height + val midy = screenPos[1] + height / 2 + var referenceX = screenPos[0] + width / 2 + if (ViewCompat.getLayoutDirection(this) == ViewCompat.LAYOUT_DIRECTION_LTR) { + val screenWidth = context.resources.displayMetrics.widthPixels + referenceX = screenWidth - referenceX // mirror + } + val cheatSheet = Toast + .makeText(context, String.format("#%06X", 0xFFFFFF and color), Toast.LENGTH_SHORT) + if (midy < displayFrame.height()) { + // Show along the top; follow action buttons + cheatSheet.setGravity(Gravity.TOP or GravityCompat.END, referenceX, + screenPos[1] + height - displayFrame.top) + } else { + // Show along the bottom center + cheatSheet.setGravity(Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL, 0, height) + } + cheatSheet.show() + } + + companion object { + + @ColorInt + @JvmStatic + private fun translucentColor(color: Int): Int { + val factor = 0.7f + val alpha = Math.round(Color.alpha(color) * factor) + val red = Color.red(color) + val green = Color.green(color) + val blue = Color.blue(color) + return Color.argb(alpha, red, green, blue) + } + + @ColorInt + @JvmStatic + fun shiftColor(@ColorInt color: Int, + @FloatRange(from = 0.0, to = 2.0) by: Float): Int { + if (by == 1f) return color + val hsv = color.toHSV() + hsv[2] *= by // value component + return hsv.toColor() + } + + @ColorInt + @JvmStatic + fun shiftColorDown(@ColorInt color: Int): Int = shiftColor(color, 0.9f) + + @ColorInt + @JvmStatic + fun shiftColorUp(@ColorInt color: Int): Int = shiftColor(color, 1.1f) + } +}
\ No newline at end of file diff --git a/core/src/main/kotlin/ca/allanwang/kau/dialogs/color/ColorPalette.kt b/core/src/main/kotlin/ca/allanwang/kau/dialogs/color/ColorPalette.kt new file mode 100644 index 0000000..22bd0d4 --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/dialogs/color/ColorPalette.kt @@ -0,0 +1,349 @@ +package ca.allanwang.kau.dialogs.color + +import android.graphics.Color + +/** + * @author Aidan Follestad (afollestad) + */ +internal object ColorPalette { + + val PRIMARY_COLORS: IntArray by lazy { + colorArrayOf( + "#F44336", + "#E91E63", + "#9C27B0", + "#673AB7", + "#3F51B5", + "#2196F3", + "#03A9F4", + "#00BCD4", + "#009688", + "#4CAF50", + "#8BC34A", + "#CDDC39", + "#FFEB3B", + "#FFC107", + "#FF9800", + "#FF5722", + "#795548", + "#9E9E9E", + "#607D8B") + } + + val PRIMARY_COLORS_SUB: Array<IntArray> by lazy { + arrayOf(colorArrayOf( + "#FFEBEE", + "#FFCDD2", + "#EF9A9A", + "#E57373", + "#EF5350", + "#F44336", + "#E53935", + "#D32F2F", + "#C62828", + "#B71C1C" + ), colorArrayOf( + "#FCE4EC", + "#F8BBD0", + "#F48FB1", + "#F06292", + "#EC407A", + "#E91E63", + "#D81B60", + "#C2185B", + "#AD1457", + "#880E4F" + ), colorArrayOf( + "#F3E5F5", + "#E1BEE7", + "#CE93D8", + "#BA68C8", + "#AB47BC", + "#9C27B0", + "#8E24AA", + "#7B1FA2", + "#6A1B9A", + "#4A148C" + ), colorArrayOf( + "#EDE7F6", + "#D1C4E9", + "#B39DDB", + "#9575CD", + "#7E57C2", + "#673AB7", + "#5E35B1", + "#512DA8", + "#4527A0", + "#311B92" + ), colorArrayOf( + "#E8EAF6", + "#C5CAE9", + "#9FA8DA", + "#7986CB", + "#5C6BC0", + "#3F51B5", + "#3949AB", + "#303F9F", + "#283593", + "#1A237E" + ), colorArrayOf( + "#E3F2FD", + "#BBDEFB", + "#90CAF9", + "#64B5F6", + "#42A5F5", + "#2196F3", + "#1E88E5", + "#1976D2", + "#1565C0", + "#0D47A1" + ), colorArrayOf( + "#E1F5FE", + "#B3E5FC", + "#81D4FA", + "#4FC3F7", + "#29B6F6", + "#03A9F4", + "#039BE5", + "#0288D1", + "#0277BD", + "#01579B" + ), colorArrayOf( + "#E0F7FA", + "#B2EBF2", + "#80DEEA", + "#4DD0E1", + "#26C6DA", + "#00BCD4", + "#00ACC1", + "#0097A7", + "#00838F", + "#006064" + ), colorArrayOf( + "#E0F2F1", + "#B2DFDB", + "#80CBC4", + "#4DB6AC", + "#26A69A", + "#009688", + "#00897B", + "#00796B", + "#00695C", + "#004D40" + ), colorArrayOf( + "#E8F5E9", + "#C8E6C9", + "#A5D6A7", + "#81C784", + "#66BB6A", + "#4CAF50", + "#43A047", + "#388E3C", + "#2E7D32", + "#1B5E20" + ), colorArrayOf( + "#F1F8E9", + "#DCEDC8", + "#C5E1A5", + "#AED581", + "#9CCC65", + "#8BC34A", + "#7CB342", + "#689F38", + "#558B2F", + "#33691E" + ), colorArrayOf( + "#F9FBE7", + "#F0F4C3", + "#E6EE9C", + "#DCE775", + "#D4E157", + "#CDDC39", + "#C0CA33", + "#AFB42B", + "#9E9D24", + "#827717" + ), colorArrayOf( + "#FFFDE7", + "#FFF9C4", + "#FFF59D", + "#FFF176", + "#FFEE58", + "#FFEB3B", + "#FDD835", + "#FBC02D", + "#F9A825", + "#F57F17" + ), colorArrayOf( + "#FFF8E1", + "#FFECB3", + "#FFE082", + "#FFD54F", + "#FFCA28", + "#FFC107", + "#FFB300", + "#FFA000", + "#FF8F00", + "#FF6F00" + ), colorArrayOf( + "#FFF3E0", + "#FFE0B2", + "#FFCC80", + "#FFB74D", + "#FFA726", + "#FF9800", + "#FB8C00", + "#F57C00", + "#EF6C00", + "#E65100" + ), colorArrayOf( + "#FBE9E7", + "#FFCCBC", + "#FFAB91", + "#FF8A65", + "#FF7043", + "#FF5722", + "#F4511E", + "#E64A19", + "#D84315", + "#BF360C" + ), colorArrayOf( + "#EFEBE9", + "#D7CCC8", + "#BCAAA4", + "#A1887F", + "#8D6E63", + "#795548", + "#6D4C41", + "#5D4037", + "#4E342E", + "#3E2723" + ), colorArrayOf( + "#FAFAFA", + "#F5F5F5", + "#EEEEEE", + "#E0E0E0", + "#BDBDBD", + "#9E9E9E", + "#757575", + "#616161", + "#424242", + "#212121" + ), colorArrayOf( + "#ECEFF1", + "#CFD8DC", + "#B0BEC5", + "#90A4AE", + "#78909C", + "#607D8B", + "#546E7A", + "#455A64", + "#37474F", + "#263238")) + } + + val ACCENT_COLORS: IntArray by lazy { + colorArrayOf( + "#FF1744", + "#F50057", + "#D500F9", + "#651FFF", + "#3D5AFE", + "#2979FF", + "#00B0FF", + "#00E5FF", + "#1DE9B6", + "#00E676", + "#76FF03", + "#C6FF00", + "#FFEA00", + "#FFC400", + "#FF9100", + "#FF3D00") + } + + val ACCENT_COLORS_SUB: Array<IntArray> by lazy { + arrayOf(colorArrayOf("#FF8A80", + "#FF5252", + "#FF1744", + "#D50000" + ), colorArrayOf( + "#FF80AB", + "#FF4081", + "#F50057", + "#C51162" + ), colorArrayOf( + "#EA80FC", + "#E040FB", + "#D500F9", + "#AA00FF" + ), colorArrayOf( + "#B388FF", + "#7C4DFF", + "#651FFF", + "#6200EA" + ), colorArrayOf( + "#8C9EFF", + "#536DFE", + "#3D5AFE", + "#304FFE" + ), colorArrayOf( + "#82B1FF", + "#448AFF", + "#2979FF", + "#2962FF" + ), colorArrayOf( + "#80D8FF", + "#40C4FF", + "#00B0FF", + "#0091EA" + ), colorArrayOf( + "#84FFFF", + "#18FFFF", + "#00E5FF", + "#00B8D4" + ), colorArrayOf( + "#A7FFEB", + "#64FFDA", + "#1DE9B6", + "#00BFA5" + ), colorArrayOf( + "#B9F6CA", + "#69F0AE", + "#00E676", + "#00C853" + ), colorArrayOf( + "#CCFF90", + "#B2FF59", + "#76FF03", + "#64DD17" + ), colorArrayOf( + "#F4FF81", + "#EEFF41", + "#C6FF00", + "#AEEA00" + ), colorArrayOf( + "#FFFF8D", + "#FFFF00", + "#FFEA00", + "#FFD600" + ), colorArrayOf( + "#FFE57F", + "#FFD740", + "#FFC400", + "#FFAB00" + ), colorArrayOf( + "#FFD180", + "#FFAB40", + "#FF9100", + "#FF6D00" + ), colorArrayOf( + "#FF9E80", + "#FF6E40", + "#FF3D00", + "#DD2C00")) + } + + fun colorArrayOf(vararg colors: String) = colors.map { Color.parseColor(it) }.toIntArray() +} + diff --git a/core/src/main/kotlin/ca/allanwang/kau/dialogs/color/ColorPickerDialog.kt b/core/src/main/kotlin/ca/allanwang/kau/dialogs/color/ColorPickerDialog.kt new file mode 100644 index 0000000..7c57c26 --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/dialogs/color/ColorPickerDialog.kt @@ -0,0 +1,82 @@ +package ca.allanwang.kau.dialogs.color + +import android.content.Context +import android.graphics.Color +import android.support.annotation.DimenRes +import android.support.annotation.StringRes +import ca.allanwang.kau.R +import ca.allanwang.kau.utils.string +import com.afollestad.materialdialogs.MaterialDialog +import com.afollestad.materialdialogs.Theme + +class ColorBuilder : ColorContract { + override var title: String? = null + override var titleRes: Int = -1 + override var allowCustom: Boolean = true + override var allowCustomAlpha: Boolean = false + override var isAccent: Boolean = false + override var defaultColor: Int = Color.BLACK + override var doneText: Int = R.string.kau_done + override var backText: Int = R.string.kau_back + override var cancelText: Int = R.string.kau_cancel + override var presetText: Int = R.string.kau_md_presets + override var customText: Int = R.string.kau_md_custom + get() = if (allowCustom) field else 0 + override var dynamicButtonColors: Boolean = true + override var circleSizeRes: Int = R.dimen.kau_color_circle_size + override var colorCallback: ((selectedColor: Int) -> Unit)? = null + override var colorsTop: IntArray? = null + override var colorsSub: Array<IntArray>? = null + override var theme: Theme? = null +} + +interface ColorContract { + var title: String? + var titleRes: Int @StringRes set + var allowCustom: Boolean + var allowCustomAlpha: Boolean + var isAccent: Boolean + var defaultColor: Int @StringRes set + var doneText: Int @StringRes set + var backText: Int @StringRes set + var cancelText: Int @StringRes set + var presetText: Int + @StringRes set + var customText: Int @StringRes set + var dynamicButtonColors: Boolean + var circleSizeRes: Int @DimenRes set + var colorCallback: ((selectedColor: Int) -> Unit)? + var colorsTop: IntArray? + var colorsSub: Array<IntArray>? + var theme: Theme? +} + +/** + * This is the extension that allows us to initialize the dialog + * Note that this returns just the dialog; you still need to call .show() to show it + */ +fun Context.colorPickerDialog(action: ColorContract.() -> Unit): MaterialDialog { + val b = ColorBuilder() + b.action() + return colorPickerDialog(b) +} + +fun Context.colorPickerDialog(contract: ColorContract): MaterialDialog { + val view = ColorPickerView(this) + val dialog = with(MaterialDialog.Builder(this)) { + title(string(contract.titleRes, contract.title) ?: string(R.string.kau_md_color_palette)) + customView(view, false) + autoDismiss(false) + positiveText(contract.doneText) + negativeText(contract.cancelText) + if (contract.allowCustom) neutralText(contract.presetText) + onPositive { dialog, _ -> contract.colorCallback?.invoke(view.selectedColor); dialog.dismiss() } + onNegative { _, _ -> view.backOrCancel() } + if (contract.allowCustom) onNeutral { _, _ -> view.toggleCustom() } + showListener { view.refreshColors() } + if (contract.theme != null) theme(contract.theme!!) + build() + } + view.bind(contract, dialog) + return dialog +}
\ No newline at end of file diff --git a/core/src/main/kotlin/ca/allanwang/kau/dialogs/color/ColorPickerView.kt b/core/src/main/kotlin/ca/allanwang/kau/dialogs/color/ColorPickerView.kt new file mode 100644 index 0000000..da864c9 --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/dialogs/color/ColorPickerView.kt @@ -0,0 +1,309 @@ +package ca.allanwang.kau.dialogs.color + +import android.content.Context +import android.graphics.Color +import android.support.annotation.ColorInt +import android.support.v4.content.res.ResourcesCompat +import android.text.Editable +import android.text.InputFilter +import android.text.TextWatcher +import android.util.AttributeSet +import android.view.View +import android.view.ViewGroup +import android.widget.* +import ca.allanwang.kau.R +import ca.allanwang.kau.utils.* +import com.afollestad.materialdialogs.DialogAction +import com.afollestad.materialdialogs.MaterialDialog +import com.afollestad.materialdialogs.color.FillGridView +import java.util.* + +/** + * Created by Allan Wang on 2017-06-08. + * + * ColorPicker component of the ColorPickerDialog + */ +internal class ColorPickerView @JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 +) : ScrollView(context, attrs, defStyleAttr) { + var selectedColor: Int = -1 + var isInSub: Boolean = false + var isInCustom: Boolean = false + var circleSize: Int = context.dimen(R.dimen.kau_color_circle_size).toInt() + val backgroundColor = context.resolveColor(R.attr.md_background_color, + if (context.resolveColor(android.R.attr.textColorPrimary).isColorDark()) Color.WHITE else 0xff424242.toInt()) + val backgroundColorTint = backgroundColor.colorToForeground() + lateinit var dialog: MaterialDialog + lateinit var builder: ColorContract + lateinit var colorsTop: IntArray + var colorsSub: Array<IntArray>? = null + var topIndex: Int = -1 + var subIndex: Int = -1 + var colorIndex: Int + get() = if (isInSub) subIndex else topIndex + set(value) { + if (isInSub) subIndex = value + else { + topIndex = value + if (colorsSub != null && colorsSub!!.size > value) { + dialog.setActionButton(DialogAction.NEGATIVE, builder.backText) + isInSub = true + invalidateGrid() + } + } + } + + + val gridView: FillGridView by bindView(R.id.md_grid) + val customFrame: LinearLayout by bindView(R.id.md_colorChooserCustomFrame) + val customColorIndicator: View by bindView(R.id.md_colorIndicator) + val hexInput: EditText by bindView(R.id.md_hexInput) + val alphaLabel: TextView by bindView(R.id.md_colorALabel) + val alphaSeekbar: SeekBar by bindView(R.id.md_colorA) + val alphaValue: TextView by bindView(R.id.md_colorAValue) + val redSeekbar: SeekBar by bindView(R.id.md_colorR) + val redValue: TextView by bindView(R.id.md_colorRValue) + val greenSeekbar: SeekBar by bindView(R.id.md_colorG) + val greenValue: TextView by bindView(R.id.md_colorGValue) + val blueSeekbar: SeekBar by bindView(R.id.md_colorB) + val blueValue: TextView by bindView(R.id.md_colorBValue) + + var customHexTextWatcher: TextWatcher? = null + var customRgbListener: SeekBar.OnSeekBarChangeListener? = null + + init { + View.inflate(context, R.layout.md_dialog_colorchooser, this) + } + + fun bind(builder: ColorContract, dialog: MaterialDialog) { + this.builder = builder + this.dialog = dialog + this.colorsTop = with(builder) { + if (colorsTop != null) colorsTop!! + else if (isAccent) ColorPalette.ACCENT_COLORS + else ColorPalette.PRIMARY_COLORS + } + this.colorsSub = with(builder) { + if (colorsTop != null) colorsSub + else if (isAccent) ColorPalette.ACCENT_COLORS_SUB + else ColorPalette.PRIMARY_COLORS_SUB + } + this.selectedColor = builder.defaultColor + if (builder.allowCustom) { + if (!builder.allowCustomAlpha) { + alphaLabel.gone() + alphaSeekbar.gone() + alphaValue.gone() + hexInput.hint = String.format("%06X", selectedColor) + hexInput.filters = arrayOf(InputFilter.LengthFilter(6)) + } else { + hexInput.hint = String.format("%08X", selectedColor) + hexInput.filters = arrayOf(InputFilter.LengthFilter(8)) + } + } + if (findColor(builder.defaultColor) || !builder.allowCustom) isInCustom = true //when toggled this will be false + toggleCustom() + } + + fun backOrCancel() { + if (isInSub) { + dialog.setActionButton(DialogAction.NEGATIVE, builder.cancelText) + //to top + isInSub = false + subIndex = -1 + invalidateGrid() + } else { + dialog.cancel() + } + } + + fun toggleCustom() { + isInCustom = !isInCustom + if (isInCustom) { + isInSub = false + if (builder.allowCustom) dialog.setActionButton(DialogAction.NEUTRAL, builder.presetText) + dialog.setActionButton(DialogAction.NEGATIVE, builder.cancelText) + customHexTextWatcher = object : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + + override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { + try { + selectedColor = Color.parseColor("#" + s.toString()) + } catch (e: IllegalArgumentException) { + selectedColor = Color.BLACK + } + + customColorIndicator.setBackgroundColor(selectedColor) + if (alphaSeekbar.isVisible()) { + val alpha = Color.alpha(selectedColor) + alphaSeekbar.progress = alpha + alphaValue.text = String.format(Locale.CANADA, "%d", alpha) + } + redSeekbar.progress = Color.red(selectedColor) + greenSeekbar.progress = Color.green(selectedColor) + blueSeekbar.progress = Color.blue(selectedColor) + isInSub = false + topIndex = -1 + subIndex = -1 + refreshColors() + } + + override fun afterTextChanged(s: Editable?) {} + } + hexInput.setText(selectedColor.toHexString(builder.allowCustomAlpha, false)) + hexInput.addTextChangedListener(customHexTextWatcher) + customRgbListener = object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { + if (fromUser) { + val color = if (builder.allowCustomAlpha) + Color.argb(alphaSeekbar.progress, + redSeekbar.progress, + greenSeekbar.progress, + blueSeekbar.progress) + else Color.rgb(redSeekbar.progress, + greenSeekbar.progress, + blueSeekbar.progress) + + hexInput.setText(color.toHexString(builder.allowCustomAlpha, false)) + } + if (builder.allowCustomAlpha) alphaValue.text = alphaSeekbar.progress.toString() + redValue.text = redSeekbar.progress.toString() + greenValue.text = greenSeekbar.progress.toString() + blueValue.text = blueSeekbar.progress.toString() + } + + override fun onStartTrackingTouch(seekBar: SeekBar) {} + + override fun onStopTrackingTouch(seekBar: SeekBar) {} + } + redSeekbar.setOnSeekBarChangeListener(customRgbListener) + greenSeekbar.setOnSeekBarChangeListener(customRgbListener) + blueSeekbar.setOnSeekBarChangeListener(customRgbListener) + if (alphaSeekbar.isVisible()) + alphaSeekbar.setOnSeekBarChangeListener(customRgbListener) + hexInput.setText(selectedColor.toHexString(alphaSeekbar.isVisible(), false)) + gridView.fadeOut(onFinish = { gridView.gone() }) + customFrame.fadeIn() + } else { + findColor(selectedColor) + if (builder.allowCustom) dialog.setActionButton(DialogAction.NEUTRAL, builder.customText) + dialog.setActionButton(DialogAction.NEGATIVE, if (isInSub) builder.backText else builder.cancelText) + gridView.fadeIn(onStart = { invalidateGrid() }) + customFrame.fadeOut(onFinish = { customFrame.gone() }) + hexInput.removeTextChangedListener(customHexTextWatcher) + customHexTextWatcher = null + alphaSeekbar.setOnSeekBarChangeListener(null) + redSeekbar.setOnSeekBarChangeListener(null) + greenSeekbar.setOnSeekBarChangeListener(null) + blueSeekbar.setOnSeekBarChangeListener(null) + customRgbListener = null + } + } + + fun refreshColors() { + if (!isInCustom) findColor(selectedColor) + //Ensure that our tinted color is still visible against the background + val visibleColor = if (selectedColor.isColorVisibleOn(backgroundColor)) selectedColor else backgroundColorTint + if (builder.dynamicButtonColors) { + dialog.getActionButton(DialogAction.POSITIVE).setTextColor(visibleColor) + dialog.getActionButton(DialogAction.NEGATIVE).setTextColor(visibleColor) + dialog.getActionButton(DialogAction.NEUTRAL).setTextColor(visibleColor) + } + if (!builder.allowCustom || !isInCustom) return + if (builder.allowCustomAlpha) + alphaSeekbar.visible().tint(visibleColor) + redSeekbar.tint(visibleColor) + greenSeekbar.tint(visibleColor) + blueSeekbar.tint(visibleColor) + hexInput.tint(visibleColor) + } + + fun findColor(@ColorInt color: Int): Boolean { + topIndex = -1 + subIndex = -1 + colorsTop.forEachIndexed { + index, topColor -> + if (findSubColor(color, index)) { + topIndex = index + return true + } + if (topColor == color) { // If no sub colors exists and top color matches + topIndex = index + return true + } + } + return false + } + + fun findSubColor(@ColorInt color: Int, topIndex: Int): Boolean { + if (colorsSub == null || colorsSub!!.size <= topIndex) return false + colorsSub!![topIndex].forEachIndexed { + index, subColor -> + if (subColor == color) { + subIndex = index + return true + } + } + return false + } + + fun invalidateGrid() { + if (gridView.adapter == null) { + gridView.adapter = ColorGridAdapter() + gridView.selector = ResourcesCompat.getDrawable(resources, R.drawable.kau_transparent, null) + } else { + (gridView.adapter as BaseAdapter).notifyDataSetChanged() + } + } + + inner class ColorGridAdapter : BaseAdapter(), OnClickListener, OnLongClickListener { + override fun onClick(v: View) { + if (v.tag != null && v.tag is String) { + val tags = (v.tag as String).split(":") + if (colorIndex == tags[0].toInt()) { + colorIndex = tags[0].toInt() //Go to sub list if exists + return + } + if (colorIndex != -1) (gridView.getChildAt(colorIndex) as CircleView).animateSelected(false) + selectedColor = tags[1].toInt() + refreshColors() + val currentSub = isInSub + colorIndex = tags[0].toInt() + if (currentSub == isInSub) (gridView.getChildAt(colorIndex) as CircleView).animateSelected(true) + //Otherwise we are invalidating our grid, so there is no point in animating + } + } + + override fun onLongClick(v: View): Boolean { + if (v.tag != null && v.tag is String) { + val tag = (v.tag as String).split(":") + val color = tag[1].toInt() + (v as CircleView).showHint(color) + return true + } + return false + } + + override fun getItem(position: Int): Any = if (isInSub) colorsSub!![topIndex][position] else colorsTop[position] + + override fun getCount(): Int = if (isInSub) colorsSub!![topIndex].size else colorsTop.size + + override fun getItemId(position: Int): Long = position.toLong() + + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + val view: CircleView = if (convertView == null) + CircleView(context).apply { layoutParams = AbsListView.LayoutParams(circleSize, circleSize) } + else + convertView as CircleView + val color: Int = if (isInSub) colorsSub!![topIndex][position] else colorsTop[position] + return view.apply { + setBackgroundColor(color) + isSelected = colorIndex == position + tag = "$position:$color" + setOnClickListener(this@ColorGridAdapter) + setOnLongClickListener(this@ColorGridAdapter) + } + } + + } +}
\ No newline at end of file diff --git a/core/src/main/kotlin/ca/allanwang/kau/email/EmailBuilder.kt b/core/src/main/kotlin/ca/allanwang/kau/email/EmailBuilder.kt new file mode 100644 index 0000000..b03a620 --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/email/EmailBuilder.kt @@ -0,0 +1,92 @@ +package ca.allanwang.kau.email + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.support.annotation.StringRes +import android.util.DisplayMetrics +import ca.allanwang.kau.R +import ca.allanwang.kau.logging.KL +import ca.allanwang.kau.utils.installerPackageName +import ca.allanwang.kau.utils.isAppInstalled +import ca.allanwang.kau.utils.string + + +/** + * Created by Allan Wang on 2017-06-20. + */ +class EmailBuilder(val email: String, val subject: String) { + var message: String = "Write here." + var deviceDetails: Boolean = true + var appInfo: Boolean = true + var footer: String? = null + private val pairs: MutableMap<String, String> = mutableMapOf() + private val packages: MutableList<Package> = mutableListOf() + + fun checkPackage(packageName: String, appName: String) = packages.add(Package(packageName, appName)) + + fun addItem(key: String, value: String) = pairs.put(key, value) + + data class Package(val packageName: String, val appName: String) + + fun execute(context: Context) { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("mailto:$email")) + intent.putExtra(Intent.EXTRA_SUBJECT, subject) + val emailBuilder = StringBuilder() + emailBuilder.append(message).append("\n\n") + if (deviceDetails) { + val deviceItems = mutableMapOf( + "OS Version" to "${System.getProperty("os.version")} (${Build.VERSION.INCREMENTAL})", + "OS API Level" to Build.DEVICE, + "Manufacturer" to Build.MANUFACTURER, + "Model (and Product)" to "${Build.MODEL} (${Build.PRODUCT})", + "Package Installer" to (context.installerPackageName ?: "None") + ) + if (context is Activity) { + val metric = DisplayMetrics() + context.windowManager.defaultDisplay.getMetrics(metric) + deviceItems.put("Screen Dimensions", "${metric.widthPixels} x ${metric.heightPixels}") + } + deviceItems.forEach { (k, v) -> emailBuilder.append("$k: $v\n") } + } + if (appInfo) { + try { + val appInfo = context.packageManager.getPackageInfo(context.packageName, 0) + emailBuilder.append("\nApp: ").append(context.packageName) + .append("\nApp Version Name: ").append(appInfo.versionName) + .append("\nApp Version Code: ").append(appInfo.versionCode).append("\n") + } catch (e: PackageManager.NameNotFoundException) { + KL.e("EmailBuilder packageInfo not found") + } + } + + if (packages.isNotEmpty()) emailBuilder.append("\n") + packages.forEach { + if (context.isAppInstalled(it.packageName)) + emailBuilder.append(String.format("\n%s is installed", it.appName)) + } + + if (pairs.isNotEmpty()) emailBuilder.append("\n") + pairs.forEach { (k, v) -> emailBuilder.append("$k: $v\n") } + + if (footer != null) + emailBuilder.append("\n").append(footer) + + intent.putExtra(Intent.EXTRA_TEXT, emailBuilder.toString()) + context.startActivity(Intent.createChooser(intent, context.resources.getString(R.string.kau_send_via))) + } +} + +fun Context.sendEmail(@StringRes emailId: Int, @StringRes subjectId: Int, builder: EmailBuilder.() -> Unit = {}) + = sendEmail(string(emailId), string(subjectId), builder) + + +fun Context.sendEmail(email: String, subject: String, builder: EmailBuilder.() -> Unit = {}) { + EmailBuilder(email, subject).apply { + builder() + execute(this@sendEmail) + } +}
\ No newline at end of file diff --git a/core/src/main/kotlin/ca/allanwang/kau/iitems/CardIItem.kt b/core/src/main/kotlin/ca/allanwang/kau/iitems/CardIItem.kt new file mode 100644 index 0000000..3380ade --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/iitems/CardIItem.kt @@ -0,0 +1,127 @@ +package ca.allanwang.kau.iitems + +import android.graphics.Color +import android.graphics.drawable.Drawable +import android.support.v7.widget.CardView +import android.support.v7.widget.RecyclerView +import android.view.View +import android.widget.Button +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import ca.allanwang.kau.R +import ca.allanwang.kau.adapters.ThemableIItem +import ca.allanwang.kau.adapters.ThemableIItemDelegate +import ca.allanwang.kau.utils.* +import com.mikepenz.fastadapter.FastAdapter +import com.mikepenz.fastadapter.IItem +import com.mikepenz.fastadapter.items.AbstractItem +import com.mikepenz.fastadapter.listeners.ClickEventHook +import com.mikepenz.iconics.typeface.IIcon + +/** + * Created by Allan Wang on 2017-06-28. + * + * Simple generic card item with an icon, title, description and button + * The icon and button are hidden by default unless values are given + */ +class CardIItem(val builder: Config.() -> Unit = {} +) : AbstractItem<CardIItem, CardIItem.ViewHolder>(), ThemableIItem by ThemableIItemDelegate() { + + companion object { + @JvmStatic fun bindClickEvents(fastAdapter: FastAdapter<IItem<*,*>>) { + fastAdapter.withEventHook(object : ClickEventHook<IItem<*,*>>() { + override fun onBindMany(viewHolder: RecyclerView.ViewHolder): List<View>? { + return if (viewHolder is ViewHolder) listOf(viewHolder.card, viewHolder.button) else null + } + + override fun onClick(v: View, position: Int, adapter: FastAdapter<IItem<*,*>>, item: IItem<*,*>) { + if (item !is CardIItem) return + with(item.configs) { + when (v.id) { + R.id.kau_card_container -> cardClick?.onClick(v) + R.id.kau_card_button -> buttonClick?.onClick(v) + else -> { + } + } + } + } + }) + } + } + + val configs = Config().apply { builder() } + + class Config { + var title: String? = null + var titleRes: Int = -1 + var desc: String? = null + var descRes: Int = -1 + var button: String? = null + var buttonRes: Int = -1 + var buttonClick: View.OnClickListener? = null + var cardClick: View.OnClickListener? = null + var image: Drawable? = null + var imageIIcon: IIcon? = null + var imageIIconColor: Int = Color.WHITE + var imageRes: Int = -1 + } + + + override fun getType(): Int = R.id.kau_item_card + + override fun getLayoutRes(): Int = R.layout.kau_iitem_card + + override fun bindView(holder: ViewHolder, payloads: MutableList<Any>?) { + super.bindView(holder, payloads) + with(holder.itemView.context) context@ { + with(configs) { + holder.title.text = string(titleRes, title) + holder.description.text = string(descRes, desc) + val buttonText = string(buttonRes, button) + if (buttonText != null) { + holder.bottomRow.visible() + holder.button.text = buttonText + holder.button.setOnClickListener(buttonClick) + } + holder.icon.setImageDrawable( + if (imageRes > 0) drawable(imageRes) + else if (imageIIcon != null) imageIIcon!!.toDrawable(this@context, sizeDp = 40, color = imageIIconColor) + else image + ) + holder.card.setOnClickListener(cardClick) + } + with(holder) { + bindTextColor(title) + bindTextColorSecondary(description) + bindAccentColor(button) + if (configs.imageIIcon != null) bindIconColor(icon) + bindBackgroundRipple(card) + } + } + } + + override fun unbindView(holder: ViewHolder) { + super.unbindView(holder) + with(holder) { + icon.gone().setImageDrawable(null) + title.text = null + description.text = null + bottomRow.gone() + button.setOnClickListener(null) + card.setOnClickListener(null) + } + } + + override fun getViewHolder(v: View): ViewHolder = ViewHolder(v) + + class ViewHolder(v: View) : RecyclerView.ViewHolder(v) { + val card: CardView by bindView(R.id.kau_card_container) + val icon: ImageView by bindView(R.id.kau_card_image) + val title: TextView by bindView(R.id.kau_card_title) + val description: TextView by bindView(R.id.kau_card_description) + val bottomRow: LinearLayout by bindView(R.id.kau_card_bottom_row) + val button: Button by bindView(R.id.kau_card_button) + } + +}
\ No newline at end of file diff --git a/core/src/main/kotlin/ca/allanwang/kau/iitems/CutoutIItem.kt b/core/src/main/kotlin/ca/allanwang/kau/iitems/CutoutIItem.kt new file mode 100644 index 0000000..627e1df --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/iitems/CutoutIItem.kt @@ -0,0 +1,48 @@ +package ca.allanwang.kau.iitems + +import android.support.v7.widget.RecyclerView +import android.view.View +import ca.allanwang.kau.R +import ca.allanwang.kau.adapters.ThemableIItem +import ca.allanwang.kau.adapters.ThemableIItemDelegate +import ca.allanwang.kau.utils.bindView +import ca.allanwang.kau.views.CutoutView +import com.mikepenz.fastadapter.items.AbstractItem + +/** + * Created by Allan Wang on 2017-06-28. + * + * Just a cutout item with some defaults in [R.layout.kau_iitem_cutout] + */ +class CutoutIItem(val config: CutoutView.() -> Unit = {} +) : AbstractItem<CutoutIItem, CutoutIItem.ViewHolder>(), ThemableIItem by ThemableIItemDelegate() { + + override fun getType(): Int = R.id.kau_item_cutout + + override fun getLayoutRes(): Int = R.layout.kau_iitem_cutout + + override fun isSelectable(): Boolean = false + + override fun bindView(holder: ViewHolder, payloads: MutableList<Any>?) { + super.bindView(holder, payloads) + with(holder) { + if (accentColor != null && themeEnabled) cutout.foregroundColor = accentColor!! + cutout.config() + } + } + + override fun unbindView(holder: ViewHolder) { + super.unbindView(holder) + with(holder) { + cutout.drawable = null + cutout.text = "Text" //back to default + } + } + + override fun getViewHolder(v: View): ViewHolder = ViewHolder(v) + + class ViewHolder(v: View) : RecyclerView.ViewHolder(v) { + val cutout: CutoutView by bindView(R.id.kau_cutout) + } + +}
\ No newline at end of file diff --git a/core/src/main/kotlin/ca/allanwang/kau/iitems/HeaderIItem.kt b/core/src/main/kotlin/ca/allanwang/kau/iitems/HeaderIItem.kt new file mode 100644 index 0000000..e994781 --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/iitems/HeaderIItem.kt @@ -0,0 +1,49 @@ +package ca.allanwang.kau.iitems + +import android.support.v7.widget.CardView +import android.support.v7.widget.RecyclerView +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import ca.allanwang.kau.R +import ca.allanwang.kau.adapters.ThemableIItem +import ca.allanwang.kau.adapters.ThemableIItemDelegate +import ca.allanwang.kau.utils.bindView +import ca.allanwang.kau.utils.string +import com.mikepenz.fastadapter.items.AbstractItem + +/** + * Created by Allan Wang on 2017-06-28. + * + * Simple Header with lots of padding on the top + * Contains only one text view + */ +class HeaderIItem(text: String? = null, var textRes: Int = -1 +) : AbstractItem<HeaderIItem, HeaderIItem.ViewHolder>(), ThemableIItem by ThemableIItemDelegate() { + + var text: String = text ?: "Header Placeholder" + + override fun getType(): Int = R.id.kau_item_header_big_margin_top + + override fun getLayoutRes(): Int = R.layout.kau_iitem_header + + override fun bindView(holder: ViewHolder, payloads: MutableList<Any>?) { + super.bindView(holder, payloads) + holder.text.text = holder.itemView.context.string(textRes, text) + bindTextColor(holder.text) + bindBackgroundColor(holder.container) + } + + override fun unbindView(holder: ViewHolder) { + super.unbindView(holder) + holder.text.text = null + } + + override fun getViewHolder(v: View): ViewHolder = ViewHolder(v) + + class ViewHolder(v: View) : RecyclerView.ViewHolder(v) { + val text: TextView by bindView(R.id.kau_header_text) + val container: CardView by bindView(R.id.kau_header_container) + } + +}
\ No newline at end of file diff --git a/core/src/main/kotlin/ca/allanwang/kau/iitems/KauIItem.kt b/core/src/main/kotlin/ca/allanwang/kau/iitems/KauIItem.kt new file mode 100644 index 0000000..00b165c --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/iitems/KauIItem.kt @@ -0,0 +1,23 @@ +package ca.allanwang.kau.iitems + +import android.support.annotation.LayoutRes +import android.support.v7.widget.RecyclerView +import android.view.View +import com.mikepenz.fastadapter.IClickable +import com.mikepenz.fastadapter.IItem +import com.mikepenz.fastadapter.items.AbstractItem + +/** + * Created by Allan Wang on 2017-07-03. + * + * Kotlin implementation of the [AbstractItem] to make things shorter + */ +open class KauIItem<Item, VH : RecyclerView.ViewHolder>( + private val type: Int, + @param:LayoutRes private val layoutRes: Int, + private val viewHolder: (v: View) -> VH +) : AbstractItem<Item, VH>() where Item : IItem<*, *>, Item : IClickable<*> { + override final fun getType(): Int = type + override final fun getViewHolder(v: View): VH = viewHolder(v) + override final fun getLayoutRes(): Int = layoutRes +}
\ No newline at end of file diff --git a/core/src/main/kotlin/ca/allanwang/kau/iitems/LibraryIItem.kt b/core/src/main/kotlin/ca/allanwang/kau/iitems/LibraryIItem.kt new file mode 100644 index 0000000..aabd9e3 --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/iitems/LibraryIItem.kt @@ -0,0 +1,100 @@ +package ca.allanwang.kau.iitems + +import android.os.Build +import android.support.v7.widget.CardView +import android.support.v7.widget.RecyclerView +import android.text.Html +import android.view.View +import android.widget.TextView +import ca.allanwang.kau.R +import ca.allanwang.kau.adapters.ThemableIItem +import ca.allanwang.kau.adapters.ThemableIItemDelegate +import ca.allanwang.kau.utils.bindView +import ca.allanwang.kau.utils.gone +import ca.allanwang.kau.utils.startLink +import ca.allanwang.kau.utils.visible +import ca.allanwang.kau.views.createSimpleRippleDrawable +import com.mikepenz.aboutlibraries.entity.Library +import com.mikepenz.fastadapter.FastAdapter +import com.mikepenz.fastadapter.IItem +import com.mikepenz.fastadapter.items.AbstractItem + +/** + * Created by Allan Wang on 2017-06-27. + */ +class LibraryIItem(val lib: Library +) : AbstractItem<LibraryIItem, LibraryIItem.ViewHolder>(), ThemableIItem by ThemableIItemDelegate() { + + companion object { + @JvmStatic fun bindClickEvents(fastAdapter: FastAdapter<IItem<*, *>>) { + fastAdapter.withOnClickListener { v, _, item, _ -> + if (item !is LibraryIItem) false + else { + val c = v.context + with(item.lib) { + c.startLink(libraryWebsite, repositoryLink, authorWebsite) + } + true + } + } + } + } + + override fun getType(): Int = R.id.kau_item_library + + override fun getLayoutRes(): Int = R.layout.kau_iitem_library + + override fun isSelectable(): Boolean = false + + override fun bindView(holder: ViewHolder, payloads: MutableList<Any>?) { + super.bindView(holder, payloads) + with(holder) { + name.text = lib.libraryName + creator.text = lib.author + description.text = if (lib.libraryDescription.isBlank()) lib.libraryDescription + else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) + Html.fromHtml(lib.libraryDescription, Html.FROM_HTML_MODE_LEGACY) + else Html.fromHtml(lib.libraryDescription) + bottomDivider.gone() + if (lib.libraryVersion?.isNotBlank() ?: false) { + bottomDivider.visible() + version.visible().text = lib.libraryVersion + } + if (lib.license?.licenseName?.isNotBlank() ?: false) { + bottomDivider.visible() + license.visible().text = lib.license?.licenseName + } + bindTextColor(name, creator) + bindTextColorSecondary(description) + bindAccentColor(license, version) + bindDividerColor(divider, bottomDivider) + bindBackgroundRipple(card) + } + } + + override fun unbindView(holder: ViewHolder) { + super.unbindView(holder) + with(holder) { + name.text = null + creator.text = null + description.text = null + bottomDivider.gone() + version.gone().text = null + license.gone().text = null + } + } + + override fun getViewHolder(v: View): ViewHolder = ViewHolder(v) + + class ViewHolder(v: View) : RecyclerView.ViewHolder(v) { + val card: CardView by bindView(R.id.lib_item_card) + val name: TextView by bindView(R.id.lib_item_name) + val creator: TextView by bindView(R.id.lib_item_author) + val description: TextView by bindView(R.id.lib_item_description) + val version: TextView by bindView(R.id.lib_item_version) + val license: TextView by bindView(R.id.lib_item_license) + val divider: View by bindView(R.id.lib_item_top_divider) + val bottomDivider: View by bindView(R.id.lib_item_bottom_divider) + } + +}
\ No newline at end of file diff --git a/core/src/main/kotlin/ca/allanwang/kau/kotlin/LazyContext.kt b/core/src/main/kotlin/ca/allanwang/kau/kotlin/LazyContext.kt new file mode 100644 index 0000000..8b59539 --- /dev/null +++ b/core/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<Interpolator> { AnimationUtils.loadInterpolator(it, id) } +fun lazyAnimation(@AnimatorRes id: Int) = lazyContext<Animation> { AnimationUtils.loadAnimation(it, id) } + +fun <T : Any> lazyContext(initializer: (context: Context) -> T): LazyContext<T> = LazyContext<T>(initializer) + +class LazyContext<out T : Any>(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 invoke(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/core/src/main/kotlin/ca/allanwang/kau/kotlin/LazyResettable.kt b/core/src/main/kotlin/ca/allanwang/kau/kotlin/LazyResettable.kt new file mode 100644 index 0000000..f8947f3 --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/kotlin/LazyResettable.kt @@ -0,0 +1,51 @@ +package ca.allanwang.kau.kotlin + +import java.io.Serializable +import kotlin.reflect.KProperty + +/** + * Created by Allan Wang on 2017-05-30. + * + * Lazy delegate that can be invalidated if needed + * https://stackoverflow.com/a/37294840/4407321 + */ +internal object UNINITIALIZED + +fun <T : Any> lazyResettable(initializer: () -> T): LazyResettable<T> = LazyResettable<T>(initializer) + +class LazyResettable<T : Any>(private val initializer: () -> T, lock: Any? = null) : Lazy<T>, Serializable { + @Volatile private var _value: Any = UNINITIALIZED + private val lock = lock ?: this + + fun invalidate() { + _value = UNINITIALIZED + } + + override val value: T + get() { + 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() + _value = typedValue + typedValue + } + } + } + + override fun isInitialized(): Boolean = _value !== UNINITIALIZED + + override fun toString(): String = if (isInitialized()) value.toString() else "Lazy value not initialized yet." + + operator fun setValue(any: Any, property: KProperty<*>, t: T) { + _value = t + } +}
\ No newline at end of file diff --git a/core/src/main/kotlin/ca/allanwang/kau/kotlin/NonReadablePropertyException.kt b/core/src/main/kotlin/ca/allanwang/kau/kotlin/NonReadablePropertyException.kt new file mode 100644 index 0000000..f3add48 --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/kotlin/NonReadablePropertyException.kt @@ -0,0 +1,12 @@ +package ca.allanwang.kau.kotlin + +/** + * Created by Allan Wang on 2017-06-24. + * + * Credits to @zsmb13 + * + * https://github.com/zsmb13/MaterialDrawerKt/blob/master/library/src/main/java/co/zsmb/materialdrawerkt/NonReadablePropertyException.kt + */ +class NonReadablePropertyException : Exception() + +fun nonReadable(): Nothing = throw NonReadablePropertyException()
\ No newline at end of file diff --git a/core/src/main/kotlin/ca/allanwang/kau/kpref/KPref.kt b/core/src/main/kotlin/ca/allanwang/kau/kpref/KPref.kt new file mode 100644 index 0000000..7fd8955 --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/kpref/KPref.kt @@ -0,0 +1,35 @@ +package ca.allanwang.kau.kpref + +import android.content.Context +import android.content.SharedPreferences + +/** + * Created by Allan Wang on 2017-06-07. + */ +open class KPref { + + lateinit private var c: Context + lateinit internal var PREFERENCE_NAME: String + private var initialized = false + + fun initialize(c: Context, preferenceName: String) { + if (initialized) throw KPrefException("KPref object $preferenceName has already been initialized; please only do so once") + initialized = true + this.c = c.applicationContext + PREFERENCE_NAME = preferenceName + } + + internal val sp: SharedPreferences by lazy { + if (!initialized) throw KPrefException("KPref object has not yet been initialized; please initialize it with a context and preference name") + c.getSharedPreferences(PREFERENCE_NAME, Context.MODE_PRIVATE) + } + + internal val prefMap: MutableMap<String, KPrefDelegate<*>> = mutableMapOf() + + fun reset() { + prefMap.values.forEach { it.invalidate() } + } + + operator fun get(key: String): KPrefDelegate<*>? = prefMap[key] + +}
\ No newline at end of file diff --git a/core/src/main/kotlin/ca/allanwang/kau/kpref/KPrefActivity.kt b/core/src/main/kotlin/ca/allanwang/kau/kpref/KPrefActivity.kt new file mode 100644 index 0000000..9a9f7d4 --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/kpref/KPrefActivity.kt @@ -0,0 +1,137 @@ +package ca.allanwang.kau.kpref + +import android.os.Bundle +import android.support.annotation.StringRes +import android.support.constraint.ConstraintLayout +import android.support.v7.app.AppCompatActivity +import android.support.v7.widget.RecyclerView +import android.support.v7.widget.Toolbar +import android.view.View +import android.view.animation.Animation +import android.view.animation.AnimationUtils +import android.widget.FrameLayout +import android.widget.ViewAnimator +import ca.allanwang.kau.R +import ca.allanwang.kau.kpref.items.KPrefItemCore +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.TextSlider +import ca.allanwang.kau.views.RippleCanvas +import com.mikepenz.fastadapter.commons.adapters.FastItemAdapter + +abstract class KPrefActivity : AppCompatActivity(), KPrefActivityContract { + + val adapter: FastItemAdapter<KPrefItemCore> + @Suppress("UNCHECKED_CAST") + get() = recycler.adapter as FastItemAdapter<KPrefItemCore> + val recycler: RecyclerView + get() = prefHolder.currentView as RecyclerView + val container: ConstraintLayout by bindView(R.id.kau_container) + 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: 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) 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) } + private val SLIDE_IN_RIGHT_ITEMS: Animation by lazy { AnimationUtils.loadAnimation(this, R.anim.kau_slide_in_right) } + private val SLIDE_OUT_LEFT_ITEMS: Animation by lazy { AnimationUtils.loadAnimation(this, R.anim.kau_slide_out_left) } + private val SLIDE_OUT_RIGHT_ITEMS: Animation by lazy { AnimationUtils.loadAnimation(this, R.anim.kau_slide_out_right) } + + /** + * Core attribute builder that is consistent throughout all items + * Leave blank to use defaults + */ + abstract fun kPrefCoreAttributes(): CoreAttributeContract.() -> Unit + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + //setup layout + setContentView(R.layout.kau_activity_kpref) + setSupportActionBar(toolbar) + if (supportActionBar != null) + with(supportActionBar!!) { + setDisplayHomeAsUpEnabled(true) + setDisplayShowHomeEnabled(true) + toolbar.setNavigationOnClickListener { onBackPressed() } + setDisplayShowTitleEnabled(false) + } + window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + statusBarColor = 0x30000000 + toolbarCanvas.set(resolveColor(R.attr.colorPrimary)) + bgCanvas.set(resolveColor(android.R.attr.colorBackground)) + prefHolder.animateFirstView = false + //setup prefs + val core = CoreAttributeBuilder() + val builder = kPrefCoreAttributes() + core.builder() + globalOptions = GlobalOptions(core, this) + showNextPrefs(R.string.kau_settings, onCreateKPrefs(savedInstanceState)) + } + + override fun onPostCreate(savedInstanceState: Bundle?) { + super.onPostCreate(savedInstanceState) + } + + override fun showNextPrefs(@StringRes toolbarTitleRes: Int, builder: KPrefAdapterBuilder.() -> Unit) { + val rv = RecyclerView(this).apply { + layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT) + setKPrefAdapter(globalOptions, builder) + } + with(prefHolder) { + inAnimation = if (animate) SLIDE_IN_RIGHT_ITEMS else null + outAnimation = if (animate) SLIDE_OUT_LEFT_ITEMS else null + addView(rv) + showNext() + } + toolbarTitle.setNextText(string(toolbarTitleRes)) + } + + override fun showPrevPrefs() { + val current = prefHolder.currentView + with(prefHolder) { + inAnimation = if (animate) SLIDE_IN_LEFT_ITEMS else null + outAnimation = if (animate) SLIDE_OUT_RIGHT_ITEMS else null + showPrevious() + removeView(current) + adapter.notifyAdapterDataSetChanged() + } + toolbarTitle.setPrevText() + } + + fun reload(vararg index: Int) { + if (index.isEmpty()) adapter.notifyAdapterDataSetChanged() + else index.forEach { adapter.notifyItemChanged(it) } + } + + override fun reloadByTitle(@StringRes vararg title: Int) { + if (title.isEmpty()) return + adapter.adapterItems.forEachIndexed { index, item -> + if (title.any { item.core.titleRes == it }) + adapter.notifyItemChanged(index) + } + } + + abstract fun onCreateKPrefs(savedInstanceState: Bundle?): KPrefAdapterBuilder.() -> Unit + + override fun onBackPressed() { + if (!backPress()) super.onBackPressed() + } + + fun backPress(): Boolean { + if (!toolbarTitle.isRoot) { + showPrevPrefs() + return true + } + return false + } +} + diff --git a/core/src/main/kotlin/ca/allanwang/kau/kpref/KPrefBinder.kt b/core/src/main/kotlin/ca/allanwang/kau/kpref/KPrefBinder.kt new file mode 100644 index 0000000..7f42d2a --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/kpref/KPrefBinder.kt @@ -0,0 +1,120 @@ +package ca.allanwang.kau.kpref + +import android.support.annotation.StringRes +import android.support.v7.widget.LinearLayoutManager +import android.support.v7.widget.RecyclerView +import ca.allanwang.kau.R +import ca.allanwang.kau.kpref.items.* +import com.mikepenz.fastadapter.commons.adapters.FastItemAdapter +import org.jetbrains.anko.doAsync +import org.jetbrains.anko.uiThread + +/** + * Created by Allan Wang on 2017-06-08. + * + * Houses all the components that can be called externally to setup the kpref mainAdapter + */ + +/** + * Base extension that will register the layout manager and mainAdapter with the given items + * Returns FastAdapter + */ +fun RecyclerView.setKPrefAdapter(globalOptions: GlobalOptions, builder: KPrefAdapterBuilder.() -> Unit): FastItemAdapter<KPrefItemCore> { + layoutManager = LinearLayoutManager(context) + val adapter = FastItemAdapter<KPrefItemCore>() + adapter.withOnClickListener { v, _, item, _ -> item.onClick(v, v.findViewById(R.id.kau_pref_inner_content)) } + this.adapter = adapter + doAsync { + val items = KPrefAdapterBuilder(globalOptions) + builder.invoke(items) + uiThread { + adapter.add(items.list) + } + } + return adapter +} + +@DslMarker +annotation class KPrefMarker + +/** + * Contains attributes shared amongst all kpref items + */ +@KPrefMarker +interface CoreAttributeContract { + var textColor: (() -> Int)? + var accentColor: (() -> Int)? +} + +/** + * Implementation of [CoreAttributeContract] + */ +class CoreAttributeBuilder : CoreAttributeContract { + override var textColor: (() -> Int)? = null + override var accentColor: (() -> Int)? = textColor +} + +interface KPrefActivityContract { + fun showNextPrefs(@StringRes toolbarTitleRes: Int, builder: KPrefAdapterBuilder.() -> Unit) + fun showPrevPrefs() + fun reloadByTitle(@StringRes vararg title: Int) +} + + +class GlobalOptions(core: CoreAttributeContract, activity: KPrefActivityContract +) : CoreAttributeContract by core, KPrefActivityContract by activity + + +/** + * Builder for kpref items + * Contains DSLs for every possible item + * The arguments are all the mandatory values plus an optional builder housing all the possible configurations + * The mandatory values are final so they cannot be edited in the builder + */ +@KPrefMarker +class KPrefAdapterBuilder(internal val globalOptions: GlobalOptions) { + + @KPrefMarker + fun header(@StringRes title: Int) + = list.add(KPrefHeader(KPrefItemCore.CoreBuilder(globalOptions, title))) + + @KPrefMarker + fun checkbox(@StringRes title: Int, + getter: (() -> Boolean), + setter: ((value: Boolean) -> Unit), + builder: KPrefItemBase.BaseContract<Boolean>.() -> Unit = {}) + = list.add(KPrefCheckbox(KPrefItemBase.BaseBuilder(globalOptions, title, getter, setter) + .apply { builder() })) + + @KPrefMarker + fun colorPicker(@StringRes title: Int, + getter: (() -> Int), + setter: ((value: Int) -> Unit), + builder: KPrefColorPicker.KPrefColorContract.() -> Unit = {}) + = list.add(KPrefColorPicker(KPrefColorPicker.KPrefColorBuilder(globalOptions, title, getter, setter) + .apply { builder() })) + + @KPrefMarker + fun <T> text(@StringRes title: Int, + getter: (() -> T), + setter: ((value: T) -> Unit), + builder: KPrefText.KPrefTextContract<T>.() -> Unit = {}) + = list.add(KPrefText<T>(KPrefText.KPrefTextBuilder<T>(globalOptions, title, getter, setter) + .apply { builder() })) + + @KPrefMarker + fun subItems(@StringRes title: Int, + itemBuilder: KPrefAdapterBuilder.() -> Unit, + builder: KPrefSubItems.KPrefSubItemsContract.() -> Unit) + = list.add(KPrefSubItems(KPrefSubItems.KPrefSubItemsBuilder(globalOptions, title, itemBuilder) + .apply { builder() })) + + @KPrefMarker + fun plainText(@StringRes title: Int, + builder: KPrefItemBase.BaseContract<Unit>.() -> Unit = {}) + = list.add(KPrefPlainText(KPrefPlainText.KPrefPlainTextBuilder(globalOptions, title) + .apply { builder() })) + + @KPrefMarker + internal val list: MutableList<KPrefItemCore> = mutableListOf() +}
\ No newline at end of file diff --git a/core/src/main/kotlin/ca/allanwang/kau/kpref/KPrefDelegate.kt b/core/src/main/kotlin/ca/allanwang/kau/kpref/KPrefDelegate.kt new file mode 100644 index 0000000..4d57ff1 --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/kpref/KPrefDelegate.kt @@ -0,0 +1,89 @@ +package ca.allanwang.kau.kpref + +/** + * Created by Allan Wang on 2017-06-07. + */ +private object UNINITIALIZED + +fun KPref.kpref(key: String, fallback: Boolean, postSetter: (value: Boolean) -> Unit = {}): KPrefDelegate<Boolean> = KPrefDelegate(key, fallback, this, postSetter) +fun KPref.kpref(key: String, fallback: Double, postSetter: (value: Float) -> Unit = {}): KPrefDelegate<Float> = KPrefDelegate(key, fallback.toFloat(), this, postSetter) +fun KPref.kpref(key: String, fallback: Float, postSetter: (value: Float) -> Unit = {}): KPrefDelegate<Float> = KPrefDelegate(key, fallback, this, postSetter) +fun KPref.kpref(key: String, fallback: Int, postSetter: (value: Int) -> Unit = {}): KPrefDelegate<Int> = KPrefDelegate(key, fallback, this, postSetter) +fun KPref.kpref(key: String, fallback: Long, postSetter: (value: Long) -> Unit = {}): KPrefDelegate<Long> = KPrefDelegate(key, fallback, this, postSetter) +fun KPref.kpref(key: String, fallback: Set<String>, postSetter: (value: Set<String>) -> Unit = {}): KPrefDelegate<StringSet> = KPrefDelegate(key, StringSet(fallback), this, postSetter) +fun KPref.kpref(key: String, fallback: String, postSetter: (value: String) -> Unit = {}): KPrefDelegate<String> = KPrefDelegate(key, fallback, this, postSetter) + +class StringSet(set: Collection<String>) : LinkedHashSet<String>(set) + +/** + * Implementation of a kpref data item + * Contains a unique key for the shared preference as well as a nonnull fallback item + * Also contains an optional mutable postSetter that will be called every time a new value is given + */ +class KPrefDelegate<T : Any> internal constructor(private val key: String, private val fallback: T, private val pref: KPref, var postSetter: (value: T) -> Unit = {}, lock: Any? = null) : Lazy<T>, java.io.Serializable { + + @Volatile private var _value: Any = UNINITIALIZED + private val lock = lock ?: this + + init { + if (pref.prefMap.containsKey(key)) + throw KPrefException("$key is already used elsewhere in preference ${pref.PREFERENCE_NAME}") + pref.prefMap.put(key, this@KPrefDelegate) + } + + fun invalidate() { + _value = UNINITIALIZED + } + + override val value: T + get() { + 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 { + _value = when (fallback) { + is Boolean -> pref.sp.getBoolean(key, fallback) + is Float -> pref.sp.getFloat(key, fallback) + is Int -> pref.sp.getInt(key, fallback) + is Long -> pref.sp.getLong(key, fallback) + is StringSet -> StringSet(pref.sp.getStringSet(key, fallback)) + is String -> pref.sp.getString(key, fallback) + else -> throw KPrefException(fallback) + } + @Suppress("UNCHECKED_CAST") + _value as T + } + } + } + + override fun isInitialized(): Boolean = _value !== UNINITIALIZED + + override fun toString(): String = if (isInitialized()) value.toString() else "Lazy kPref $key not initialized yet." + + operator fun setValue(any: Any, property: kotlin.reflect.KProperty<*>, t: T) { + _value = t + val editor = pref.sp.edit() + when (t) { + is Boolean -> editor.putBoolean(key, t) + is Float -> editor.putFloat(key, t) + is Int -> editor.putInt(key, t) + is Long -> editor.putLong(key, t) + is StringSet -> editor.putStringSet(key, t) + is String -> editor.putString(key, t) + else -> throw KPrefException(t) + } + editor.apply() + postSetter.invoke(t) + } +} + +class KPrefException(message: String) : IllegalAccessException(message) { + constructor(element: Any?) : this("Invalid type in pref cache: ${element?.javaClass?.simpleName ?: "null"}") +}
\ No newline at end of file diff --git a/core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefCheckbox.kt b/core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefCheckbox.kt new file mode 100644 index 0000000..22cc927 --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefCheckbox.kt @@ -0,0 +1,33 @@ +package ca.allanwang.kau.kpref.items + +import android.view.View +import android.widget.CheckBox +import ca.allanwang.kau.R +import ca.allanwang.kau.kpref.KPrefMarker +import ca.allanwang.kau.utils.tint + +/** + * Created by Allan Wang on 2017-06-07. + * + * Checkbox preference + * When clicked, will toggle the preference and the apply the result to the checkbox + */ +class KPrefCheckbox(builder: BaseContract<Boolean>) : KPrefItemBase<Boolean>(builder) { + + override fun defaultOnClick(itemView: View, innerContent: View?): Boolean { + pref = !pref + (innerContent as CheckBox).isChecked = pref + return true + } + + override fun onPostBindView(viewHolder: ViewHolder, textColor: Int?, accentColor: Int?) { + super.onPostBindView(viewHolder, textColor, accentColor) + val checkbox = viewHolder.bindInnerView<CheckBox>(R.layout.kau_preference_checkbox) + if (accentColor != null) checkbox.tint(accentColor) + checkbox.isChecked = pref + checkbox.jumpDrawablesToCurrentState() //Cancel the animation + } + + override fun getType(): Int = R.id.kau_item_pref_checkbox + +}
\ No newline at end of file diff --git a/core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefColorPicker.kt b/core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefColorPicker.kt new file mode 100644 index 0000000..c573939 --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefColorPicker.kt @@ -0,0 +1,73 @@ +package ca.allanwang.kau.kpref.items + +import android.view.View +import ca.allanwang.kau.R +import ca.allanwang.kau.dialogs.color.CircleView +import ca.allanwang.kau.dialogs.color.ColorBuilder +import ca.allanwang.kau.dialogs.color.ColorContract +import ca.allanwang.kau.dialogs.color.colorPickerDialog +import ca.allanwang.kau.kpref.CoreAttributeContract +import ca.allanwang.kau.kpref.GlobalOptions +import ca.allanwang.kau.kpref.KPrefMarker + +/** + * Created by Allan Wang on 2017-06-07. + * + * ColorPicker preference + * When a color is successfully selected in the dialog, it will be saved as an int + */ +class KPrefColorPicker(val builder: KPrefColorContract) : KPrefItemBase<Int>(builder) { + + override fun onPostBindView(viewHolder: ViewHolder, textColor: Int?, accentColor: Int?) { + super.onPostBindView(viewHolder, textColor, accentColor) + builder.apply { + titleRes = core.titleRes + colorCallback = { + pref = it + } + } + if (builder.showPreview) { + val preview = viewHolder.bindInnerView<CircleView>(R.layout.kau_preference_color_preview) + preview.setBackgroundColor(pref) + preview.withBorder = true + builder.apply { + colorCallback = { + pref = it + if (builder.showPreview) + preview.setBackgroundColor(it) + } + } + } + } + + + override fun defaultOnClick(itemView: View, innerContent: View?): Boolean { + builder.apply { + defaultColor = pref //update color + } + itemView.context.colorPickerDialog(builder).show() + return true + } + + /** + * Extension of the base contract and [ColorContract] along with a showPreview option + */ + interface KPrefColorContract : BaseContract<Int>, ColorContract { + var showPreview: Boolean + } + + /** + * Default implementation of [KPrefColorContract] + */ + class KPrefColorBuilder(globalOptions: GlobalOptions, + override var titleRes: Int, + getter: () -> Int, + setter: (value: Int) -> Unit + ) : KPrefColorContract, BaseContract<Int> by BaseBuilder<Int>(globalOptions, titleRes, getter, setter), + ColorContract by ColorBuilder() { + override var showPreview: Boolean = true + } + + override fun getType(): Int = R.id.kau_item_pref_color_picker + +}
\ No newline at end of file diff --git a/core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefHeader.kt b/core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefHeader.kt new file mode 100644 index 0000000..fa8efff --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefHeader.kt @@ -0,0 +1,25 @@ +package ca.allanwang.kau.kpref.items + +import android.view.View +import ca.allanwang.kau.R +import ca.allanwang.kau.kpref.KPrefMarker + +/** + * Created by Allan Wang on 2017-06-07. + * + * Header preference + * This view just holds a title and is not clickable. It is styled using the accent color + */ +class KPrefHeader(builder: CoreContract) : KPrefItemCore(builder) { + + override fun getLayoutRes(): Int = R.layout.kau_preference_header + + override fun onPostBindView(viewHolder: ViewHolder, textColor: Int?, accentColor: Int?) { + if (accentColor != null) viewHolder.title.setTextColor(accentColor) + } + + override fun onClick(itemView: View, innerContent: View?): Boolean = true + + override fun getType() = R.id.kau_item_pref_header + +}
\ No newline at end of file diff --git a/core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefItemBase.kt b/core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefItemBase.kt new file mode 100644 index 0000000..bb0f0a3 --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefItemBase.kt @@ -0,0 +1,85 @@ +package ca.allanwang.kau.kpref.items + +import android.support.annotation.CallSuper +import android.view.View +import ca.allanwang.kau.R +import ca.allanwang.kau.kpref.CoreAttributeContract +import ca.allanwang.kau.kpref.GlobalOptions +import ca.allanwang.kau.utils.resolveDrawable + +/** + * Created by Allan Wang on 2017-06-05. + * + * Base class for pref setters that include the Shared Preference hooks + */ +abstract class KPrefItemBase<T>(val base: BaseContract<T>) : KPrefItemCore(base) { + + open var pref: T + get() = base.getter.invoke() + set(value) { + base.setter.invoke(value) + } + + var enabled: Boolean = true + + init { + if (base.onClick == null) base.onClick = { + itemView, innerContent, _ -> + defaultOnClick(itemView, innerContent) + } + } + + abstract fun defaultOnClick(itemView: View, innerContent: View?): Boolean + + @CallSuper + override fun onPostBindView(viewHolder: ViewHolder, textColor: Int?, accentColor: Int?) { + enabled = base.enabler.invoke() + with(viewHolder) { + if (!enabled) container?.background = null + container?.alpha = if (enabled) 1.0f else 0.3f + } + } + + override final fun onClick(itemView: View, innerContent: View?): Boolean { + return if (enabled) base.onClick?.invoke(itemView, innerContent, this) ?: false + else base.onDisabledClick?.invoke(itemView, innerContent, this) ?: false + } + + override fun unbindView(holder: ViewHolder) { + super.unbindView(holder) + with(holder) { + container?.isEnabled = true + container?.background = itemView.context.resolveDrawable(android.R.attr.selectableItemBackground) + container?.alpha = 1.0f + } + } + + override final fun getLayoutRes(): Int = R.layout.kau_preference + + /** + * Extension of the core contract + * Since everything that extends the base is an actual preference, there must be a getter and setter + * The rest are optional and will have their defaults + */ + interface BaseContract<T> : CoreContract { + var enabler: () -> Boolean + var onClick: ((itemView: View, innerContent: View?, item: KPrefItemBase<T>) -> Boolean)? + var onDisabledClick: ((itemView: View, innerContent: View?, item: KPrefItemBase<T>) -> Boolean)? + val getter: () -> T + val setter: (value: T) -> Unit + } + + /** + * Default implementation of [BaseContract] + */ + class BaseBuilder<T>(globalOptions: GlobalOptions, + titleRes: Int, + override val getter: () -> T, + override val setter: (value: T) -> Unit + ) : CoreContract by CoreBuilder(globalOptions, titleRes), BaseContract<T> { + override var enabler: () -> Boolean = { true } + override var onClick: ((itemView: View, innerContent: View?, item: KPrefItemBase<T>) -> Boolean)? = null + override var onDisabledClick: ((itemView: View, innerContent: View?, item: KPrefItemBase<T>) -> Boolean)? = null + } + +}
\ No newline at end of file diff --git a/core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefItemCore.kt b/core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefItemCore.kt new file mode 100644 index 0000000..5f684ba --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefItemCore.kt @@ -0,0 +1,126 @@ +package ca.allanwang.kau.kpref.items + +import android.support.annotation.CallSuper +import android.support.annotation.IdRes +import android.support.annotation.LayoutRes +import android.support.annotation.StringRes +import android.support.v7.widget.RecyclerView +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import ca.allanwang.kau.R +import ca.allanwang.kau.adapters.ThemableIItem +import ca.allanwang.kau.adapters.ThemableIItemDelegate +import ca.allanwang.kau.kpref.GlobalOptions +import ca.allanwang.kau.kpref.KPrefMarker +import ca.allanwang.kau.utils.* +import com.mikepenz.fastadapter.items.AbstractItem +import com.mikepenz.iconics.typeface.IIcon + +/** + * Created by Allan Wang on 2017-06-05. + * + * Core class containing nothing but the view items + */ + +abstract class KPrefItemCore(val core: CoreContract) : AbstractItem<KPrefItemCore, KPrefItemCore.ViewHolder>(), + ThemableIItem by ThemableIItemDelegate() { + + override final fun getViewHolder(v: View) = ViewHolder(v) + + @CallSuper + override fun bindView(viewHolder: ViewHolder, payloads: List<Any>) { + super.bindView(viewHolder, payloads) + with(viewHolder) { + val context = itemView.context + title.text = context.string(core.titleRes) + if (core.descRes > 0) + desc?.visible()?.setText(core.descRes) + else + desc?.gone() + if (core.iicon != null) icon?.visible()?.setIcon(core.iicon, 24) + else icon?.gone() + innerFrame?.removeAllViews() + val textColor = core.globalOptions.textColor?.invoke() + if (textColor != null) { + title.setTextColor(textColor) + desc?.setTextColor(textColor) + } + val accentColor = core.globalOptions.accentColor?.invoke() + if (accentColor != null) { + icon?.drawable?.setTint(accentColor) + } + onPostBindView(this, textColor, accentColor) + } + } + + abstract fun onPostBindView(viewHolder: ViewHolder, textColor: Int?, accentColor: Int?) + + abstract fun onClick(itemView: View, innerContent: View?): Boolean + + override fun unbindView(holder: ViewHolder) { + super.unbindView(holder) + with(holder) { + title.text = null + desc?.text = null + icon?.setImageDrawable(null) +// innerFrame?.removeAllViews() + } + } + + /** + * Core values for all kpref items + */ + @KPrefMarker + interface CoreContract { + val globalOptions: GlobalOptions + @get:StringRes val titleRes: Int + var descRes: Int + @StringRes get + var iicon: IIcon? + + /** + * Attempts to reload current item by identifying it with its [titleRes] + */ + fun reloadSelf() + } + + /** + * Default implementation of [CoreContract] + */ + class CoreBuilder(override val globalOptions: GlobalOptions, + override @param:StringRes val titleRes: Int) : CoreContract { + override var descRes: Int = -1 + override var iicon: IIcon? = null + + override fun reloadSelf() { + globalOptions.reloadByTitle(titleRes) + } + } + + class ViewHolder(v: View) : RecyclerView.ViewHolder(v) { + val title: TextView by bindView(R.id.kau_pref_title) + val container: ViewGroup? by bindOptionalView(R.id.kau_pref_container) + val desc: TextView? by bindOptionalView(R.id.kau_pref_desc) + val icon: ImageView? by bindOptionalView(R.id.kau_pref_icon) + val innerFrame: LinearLayout? by bindOptionalView(R.id.kau_pref_inner_frame) + val innerContent: View? + get() = itemView.findViewById(R.id.kau_pref_inner_content) + + inline fun <reified T : View> bindInnerView(@LayoutRes id: Int): T { + if (innerFrame == null) throw IllegalStateException("Cannot bind inner view when innerFrame does not exist") + if (innerContent !is T) { + innerFrame!!.removeAllViews() + LayoutInflater.from(innerFrame!!.context).inflate(id, innerFrame) + } + return innerContent as T + } + + inline fun <reified T : View> getInnerView() = innerContent as T + + operator fun get(@IdRes id: Int): View = itemView.findViewById(id) + } +}
\ No newline at end of file diff --git a/core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefPlainText.kt b/core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefPlainText.kt new file mode 100644 index 0000000..a782430 --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefPlainText.kt @@ -0,0 +1,29 @@ +package ca.allanwang.kau.kpref.items + +import android.view.View +import ca.allanwang.kau.R +import ca.allanwang.kau.kpref.GlobalOptions + +/** + * Created by Allan Wang on 2017-06-14. + * + * Just text with the core options. Extends base preference but has an empty getter and setter + * Useful replacement of [KPrefText] when nothing is displayed on the right side, + * and when the preference is completely handled by the click + * + */ +class KPrefPlainText(val builder: KPrefPlainTextBuilder) : KPrefItemBase<Unit>(builder) { + + override fun defaultOnClick(itemView: View, innerContent: View?): Boolean { + //nothing + return true + } + + class KPrefPlainTextBuilder( + globalOptions: GlobalOptions, + titleRes: Int + ) : BaseContract<Unit> by BaseBuilder(globalOptions, titleRes, {}, {}) + + override fun getType(): Int = R.id.kau_item_pref_plain_text + +}
\ No newline at end of file diff --git a/core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefSubItems.kt b/core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefSubItems.kt new file mode 100644 index 0000000..51625ab --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefSubItems.kt @@ -0,0 +1,43 @@ +package ca.allanwang.kau.kpref.items + +import android.view.View +import ca.allanwang.kau.R +import ca.allanwang.kau.kpref.GlobalOptions +import ca.allanwang.kau.kpref.KPrefAdapterBuilder + +/** + * Created by Allan Wang on 2017-06-14. + * + * Sub item preference + * When clicked, will navigate to a new set of preferences and add the old list to a stack + * + */ +class KPrefSubItems(val builder: KPrefSubItemsContract) : KPrefItemCore(builder) { + + override fun onClick(itemView: View, innerContent: View?): Boolean { + builder.globalOptions.showNextPrefs(builder.titleRes, builder.itemBuilder) + return true + } + + override fun getLayoutRes(): Int = R.layout.kau_preference + + override fun onPostBindView(viewHolder: ViewHolder, textColor: Int?, accentColor: Int?) {} + /** + * Extension of the base contract with an optional text getter + */ + interface KPrefSubItemsContract : CoreContract { + val itemBuilder: KPrefAdapterBuilder.() -> Unit + } + + /** + * Default implementation of [KPrefTextContract] + */ + class KPrefSubItemsBuilder( + globalOptions: GlobalOptions, + titleRes: Int, + override val itemBuilder: KPrefAdapterBuilder.() -> Unit + ) : KPrefSubItemsContract, CoreContract by CoreBuilder(globalOptions, titleRes) + + override fun getType(): Int = R.id.kau_item_pref_sub_item + +}
\ No newline at end of file diff --git a/core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefText.kt b/core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefText.kt new file mode 100644 index 0000000..8662b6a --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/kpref/items/KPrefText.kt @@ -0,0 +1,62 @@ +package ca.allanwang.kau.kpref.items + +import android.view.View +import android.widget.TextView +import ca.allanwang.kau.R +import ca.allanwang.kau.kpref.GlobalOptions +import ca.allanwang.kau.utils.toast + +/** + * Created by Allan Wang on 2017-06-14. + * + * Text preference + * Holds a textview to display data on the right + * This is still a generic preference + * + */ +class KPrefText<T>(val builder: KPrefTextContract<T>) : KPrefItemBase<T>(builder) { + + /** + * Automatically reload on set + */ + override var pref: T + get() = base.getter.invoke() + set(value) { + base.setter.invoke(value) + builder.reloadSelf() + } + + override fun defaultOnClick(itemView: View, innerContent: View?): Boolean { + itemView.context.toast("No click function set") + return true + } + + override fun onPostBindView(viewHolder: ViewHolder, textColor: Int?, accentColor: Int?) { + super.onPostBindView(viewHolder, textColor, accentColor) + val textview = viewHolder.bindInnerView<TextView>(R.layout.kau_preference_text) + if (textColor != null) textview.setTextColor(textColor) + textview.text = builder.textGetter.invoke(pref) + } + + /** + * Extension of the base contract with an optional text getter + */ + interface KPrefTextContract<T> : BaseContract<T> { + var textGetter: (T) -> String? + } + + /** + * Default implementation of [KPrefTextContract] + */ + class KPrefTextBuilder<T>( + globalOptions: GlobalOptions, + titleRes: Int, + getter: () -> T, + setter: (value: T) -> Unit + ) : KPrefTextContract<T>, BaseContract<T> by BaseBuilder<T>(globalOptions, titleRes, getter, setter) { + override var textGetter: (T) -> String? = { it?.toString() } + } + + override fun getType(): Int = R.id.kau_item_pref_text + +}
\ No newline at end of file diff --git a/core/src/main/kotlin/ca/allanwang/kau/logging/KL.kt b/core/src/main/kotlin/ca/allanwang/kau/logging/KL.kt new file mode 100644 index 0000000..4fa3360 --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/logging/KL.kt @@ -0,0 +1,6 @@ +package ca.allanwang.kau.logging + +/** + * Created by Allan Wang on 2017-06-19. + */ +object KL : TimberLogger("KAU")
\ No newline at end of file diff --git a/core/src/main/kotlin/ca/allanwang/kau/logging/TimberLogger.kt b/core/src/main/kotlin/ca/allanwang/kau/logging/TimberLogger.kt new file mode 100644 index 0000000..5969fd5 --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/logging/TimberLogger.kt @@ -0,0 +1,19 @@ +package ca.allanwang.kau.logging + +import timber.log.Timber + + +/** + * Created by Allan Wang on 2017-05-28. + * + * Timber extension that will embed the tag as part of the message for each log item + */ +open class TimberLogger(tag: String) { + internal val TAG = "$tag: %s" + fun e(s: String) = Timber.e(TAG, s) + fun e(t: Throwable, s: String = "error") = Timber.e(t, TAG, s) + fun d(s: String) = Timber.d(TAG, s) + fun i(s: String) = Timber.i(TAG, s) + fun v(s: String) = Timber.v(TAG, s) + fun eThrow(s: String) = e(Throwable(s)) +}
\ No newline at end of file diff --git a/core/src/main/kotlin/ca/allanwang/kau/permissions/PermissionManager.kt b/core/src/main/kotlin/ca/allanwang/kau/permissions/PermissionManager.kt new file mode 100644 index 0000000..6f93c9f --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/permissions/PermissionManager.kt @@ -0,0 +1,58 @@ +package ca.allanwang.kau.permissions + +import android.app.Activity +import android.content.Context +import android.support.v4.app.ActivityCompat +import ca.allanwang.kau.logging.KL +import ca.allanwang.kau.utils.KauException +import ca.allanwang.kau.utils.buildIsMarshmallowAndUp +import ca.allanwang.kau.utils.hasPermission +import java.lang.ref.WeakReference + +/** + * Created by Allan Wang on 2017-07-03. + */ +internal object PermissionManager { + + var requestInProgress = false + val pendingResults: MutableList<WeakReference<PermissionResult>> by lazy { mutableListOf<WeakReference<PermissionResult>>() } + + operator fun invoke(context: Context, permissions: Array<out String>, callback: (granted: Boolean, deniedPerm: String?) -> Unit) { + KL.d("Requesting permissions: ${permissions.contentToString()}") + if (!buildIsMarshmallowAndUp) return callback(true, null) + val missingPermissions = permissions.filter { !context.hasPermission(it) } + if (missingPermissions.isEmpty()) return callback(true, null) + pendingResults.add(WeakReference(PermissionResult(permissions, callback = callback))) + if (!requestInProgress) { + requestInProgress = true + requestPermissions(context, missingPermissions.toTypedArray()) + } else KL.d("Request is postponed since another one is still in progress; did you remember to override onRequestPermissionsResult?") + } + + @Synchronized internal fun requestPermissions(context: Context, permissions: Array<out String>) { + val activity = (context as? Activity) ?: throw KauException("Context is not an instance of an activity; cannot request permissions") + ActivityCompat.requestPermissions(activity, permissions, 1) + } + + fun onRequestPermissionsResult(context: Context, permissions: Array<out String>, grantResults: IntArray) { + val count = Math.min(permissions.size, grantResults.size) + val iter = pendingResults.iterator() + while (iter.hasNext()) { + val action = iter.next().get() + if ((0 until count).any { action?.onResult(permissions[it], grantResults[it]) ?: true }) + iter.remove() + } + if (pendingResults.isEmpty()) + requestInProgress = false + else { + val action = pendingResults.map { it.get() }.firstOrNull { it != null } + if (action == null) { //actions have been unlinked from their weak references + pendingResults.clear() + requestInProgress = false + return + } + requestPermissions(context, action.permissions.toTypedArray()) + } + } + +}
\ No newline at end of file diff --git a/core/src/main/kotlin/ca/allanwang/kau/permissions/PermissionResult.kt b/core/src/main/kotlin/ca/allanwang/kau/permissions/PermissionResult.kt new file mode 100644 index 0000000..14bfdff --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/permissions/PermissionResult.kt @@ -0,0 +1,26 @@ +package ca.allanwang.kau.permissions + +import android.content.pm.PackageManager + +/** + * Created by Allan Wang on 2017-07-03. + */ +class PermissionResult(permissions: Array<out String>, val callback: (granted: Boolean, deniedPerm: String?) -> Unit) { + val permissions = mutableSetOf(*permissions) + + /** + * Called from the manager whenever a permission has changed + * Returns true if result is completed, false otherwise + */ + fun onResult(permission: String, result: Int): Boolean { + if (result != PackageManager.PERMISSION_GRANTED) { + callback(false, permission) + permissions.clear() + return true + } + permissions.remove(permission) + if (permissions.isNotEmpty()) return false + callback(true, null) + return true + } +}
\ No newline at end of file diff --git a/core/src/main/kotlin/ca/allanwang/kau/permissions/Permissions.kt b/core/src/main/kotlin/ca/allanwang/kau/permissions/Permissions.kt new file mode 100644 index 0000000..fd43102 --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/permissions/Permissions.kt @@ -0,0 +1,68 @@ +package ca.allanwang.kau.permissions + +import android.Manifest +import android.app.Activity +import android.content.Context + + +/** + * Created by Allan Wang on 2017-07-02. + * + * Bindings for the permission manager + */ + +/** + * Hook that should be added inside all [Activity.onRequestPermissionsResult] so that the Permission manager can handle the responses + */ +fun Activity.kauOnRequestPermissionsResult(permissions: Array<out String>, grantResults: IntArray) + = PermissionManager.onRequestPermissionsResult(this, permissions, grantResults) + +/** + * Request a permission with a callback + * In reality, an activity is needed to fulfill the request, but a context is enough if those permissions are already granted + * To be safe, you may want to check that the context can be casted successfully first + * The [callback] returns [granted], which is true if all permissions are granted + * [deniedPerm] is the first denied permission, if granted is false + */ +fun Context.kauRequestPermissions(vararg permissions: String, callback: (granted: Boolean, deniedPerm: String?) -> Unit) + = PermissionManager(this, permissions, callback) + +/** + * See http://developer.android.com/guide/topics/security/permissions.html#normal-dangerous for a + * list of 'dangerous' permissions that require a permission request on API 23. + */ +const val PERMISSION_READ_CALENDAR = Manifest.permission.READ_CALENDAR + +const val PERMISSION_WRITE_CALENDAR = Manifest.permission.WRITE_CALENDAR + +const val PERMISSION_CAMERA = Manifest.permission.CAMERA + +const val PERMISSION_READ_CONTACTS = Manifest.permission.READ_CONTACTS +const val PERMISSION_WRITE_CONTACTS = Manifest.permission.WRITE_CONTACTS +const val PERMISSION_GET_ACCOUNTS = Manifest.permission.GET_ACCOUNTS + +const val PERMISSION_ACCESS_FINE_LOCATION = Manifest.permission.ACCESS_FINE_LOCATION +const val PERMISSION_ACCESS_COARSE_LOCATION = Manifest.permission.ACCESS_COARSE_LOCATION + +const val PERMISSION_RECORD_AUDIO = Manifest.permission.RECORD_AUDIO + +const val PERMISSION_READ_PHONE_STATE = Manifest.permission.READ_PHONE_STATE +const val PERMISSION_CALL_PHONE = Manifest.permission.CALL_PHONE +const val PERMISSION_READ_CALL_LOG = Manifest.permission.READ_CALL_LOG +const val PERMISSION_WRITE_CALL_LOG = Manifest.permission.WRITE_CALL_LOG +const val PERMISSION_ADD_VOICEMAIL = Manifest.permission.ADD_VOICEMAIL +const val PERMISSION_USE_SIP = Manifest.permission.USE_SIP +const val PERMISSION_PROCESS_OUTGOING_CALLS = Manifest.permission.PROCESS_OUTGOING_CALLS + +const val PERMISSION_BODY_SENSORS = Manifest.permission.BODY_SENSORS + +const val PERMISSION_SEND_SMS = Manifest.permission.SEND_SMS +const val PERMISSION_RECEIVE_SMS = Manifest.permission.RECEIVE_SMS +const val PERMISSION_READ_SMS = Manifest.permission.READ_SMS +const val PERMISSION_RECEIVE_WAP_PUSH = Manifest.permission.RECEIVE_WAP_PUSH +const val PERMISSION_RECEIVE_MMS = Manifest.permission.RECEIVE_MMS + +const val PERMISSION_READ_EXTERNAL_STORAGE = Manifest.permission.READ_EXTERNAL_STORAGE +const val PERMISSION_WRITE_EXTERNAL_STORAGE = Manifest.permission.WRITE_EXTERNAL_STORAGE + +const val PERMISSION_SYSTEM_ALERT_WINDOW = Manifest.permission.SYSTEM_ALERT_WINDOW
\ No newline at end of file diff --git a/core/src/main/kotlin/ca/allanwang/kau/searchview/SearchItem.kt b/core/src/main/kotlin/ca/allanwang/kau/searchview/SearchItem.kt new file mode 100644 index 0000000..ac8ec2e --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/searchview/SearchItem.kt @@ -0,0 +1,80 @@ +package ca.allanwang.kau.searchview + +import android.graphics.Typeface +import android.graphics.drawable.Drawable +import android.support.constraint.ConstraintLayout +import android.support.v7.widget.RecyclerView +import android.text.Spannable +import android.text.SpannableStringBuilder +import android.text.style.StyleSpan +import android.view.View +import android.widget.ImageView +import android.widget.TextView +import ca.allanwang.kau.R +import ca.allanwang.kau.iitems.KauIItem +import ca.allanwang.kau.utils.* +import com.mikepenz.google_material_typeface_library.GoogleMaterial +import com.mikepenz.iconics.typeface.IIcon + +/** + * Created by Allan Wang on 2017-06-23. + * + * A holder for each individual search item + * Contains a [key] which acts as a unique identifier (eg url) + * and a [content] which is displayed in the item + */ +class SearchItem(val key: String, + val content: String = key, + val description: String? = null, + val iicon: IIcon? = GoogleMaterial.Icon.gmd_search, + val image: Drawable? = null +) : KauIItem<SearchItem, SearchItem.ViewHolder>( + R.id.kau_item_search, + R.layout.kau_search_iitem, + {ViewHolder(it)} +) { + + companion object { + @JvmStatic var foregroundColor: Int = 0xdd000000.toInt() + @JvmStatic var backgroundColor: Int = 0xfffafafa.toInt() + } + + var styledContent: SpannableStringBuilder? = null + + /** + * Highlight the subText if it is present in the content + */ + fun withHighlights(subText: String) { + val index = content.indexOf(subText) + if (index == -1) return + styledContent = SpannableStringBuilder(content) + styledContent!!.setSpan(StyleSpan(Typeface.BOLD), index, index + subText.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + } + + override fun bindView(holder: ViewHolder, payloads: MutableList<Any>?) { + super.bindView(holder, payloads) + holder.title.setTextColor(foregroundColor) + holder.desc.setTextColor(foregroundColor.adjustAlpha(0.6f)) + + if (image != null) holder.icon.setImageDrawable(image) + else holder.icon.setIcon(iicon, sizeDp = 18, color = foregroundColor) + + holder.container.setRippleBackground(foregroundColor, backgroundColor) + holder.title.text = styledContent ?: content + if (description?.isNotBlank() ?: false) holder.desc.visible().text = description + } + + override fun unbindView(holder: ViewHolder) { + super.unbindView(holder) + holder.title.text = null + holder.desc.gone().text = null + holder.icon.setImageDrawable(null) + } + + class ViewHolder(v: View) : RecyclerView.ViewHolder(v) { + val icon: ImageView by bindView(R.id.search_icon) + val title: TextView by bindView(R.id.search_title) + val desc: TextView by bindView(R.id.search_desc) + val container: ConstraintLayout by bindView(R.id.search_item_frame) + } +}
\ No newline at end of file diff --git a/core/src/main/kotlin/ca/allanwang/kau/searchview/SearchView.kt b/core/src/main/kotlin/ca/allanwang/kau/searchview/SearchView.kt new file mode 100644 index 0000000..c077a06 --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/searchview/SearchView.kt @@ -0,0 +1,412 @@ +package ca.allanwang.kau.searchview + +import android.app.Activity +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Color +import android.support.annotation.ColorInt +import android.support.annotation.IdRes +import android.support.annotation.StringRes +import android.support.transition.AutoTransition +import android.support.v7.widget.AppCompatEditText +import android.support.v7.widget.LinearLayoutManager +import android.support.v7.widget.RecyclerView +import android.util.AttributeSet +import android.view.* +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.ProgressBar +import ca.allanwang.kau.R +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.BoundedCardView +import com.jakewharton.rxbinding2.widget.RxTextView +import com.mikepenz.fastadapter.commons.adapters.FastItemAdapter +import com.mikepenz.google_material_typeface_library.GoogleMaterial +import com.mikepenz.iconics.typeface.IIcon +import io.reactivex.Observable +import io.reactivex.schedulers.Schedulers +import org.jetbrains.anko.runOnUiThread + + +/** + * Created by Allan Wang on 2017-06-23. + * + * A materialized SearchView with complete theming and observables + * This view can be added programmatically and configured using the [Configs] DSL + * It is preferred to add the view through an activity, but it can be attached to any ViewGroup + * Beware of where specifically this is added, as its view or the keyboard may affect positioning + * + * Huge thanks to @lapism for his base + * https://github.com/lapism/SearchView + */ +class SearchView @JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 +) : FrameLayout(context, attrs, defStyleAttr) { + + /** + * Collection of all possible arguments when building the SearchView + * Everything is made as opened as possible so other components may be found in the [SearchView] + * However, these are the notable options put together an an inner class for better visibility + */ + inner class Configs { + /** + * In the searchview, foreground color accounts for all text colors and icon colors + * Various alpha levels may be used for sub texts/dividers etc + */ + var foregroundColor: Int + get() = SearchItem.foregroundColor + set(value) { + if (SearchItem.foregroundColor == value) return + SearchItem.foregroundColor = value + tintForeground(value) + } + /** + * Namely the background for the card and recycler view + */ + var backgroundColor: Int + get() = SearchItem.backgroundColor + set(value) { + if (SearchItem.backgroundColor == value) return + SearchItem.backgroundColor = value + tintBackground(value) + } + /** + * Icon for the leftmost ImageView, which typically contains the hamburger menu/back arror + */ + var navIcon: IIcon? = GoogleMaterial.Icon.gmd_arrow_back + set(value) { + field = value + iconNav.setSearchIcon(value) + if (value == null) iconNav.gone() + } + + /** + * Optional icon just to the left of the clear icon + * This is not implemented by default, but can be used for anything, such as mic or redirects + * Returns the extra imageview + * Set the iicon as null to hide the extra icon + */ + fun setExtraIcon(iicon: IIcon?, onClick: OnClickListener?): ImageView { + iconExtra.setSearchIcon(iicon) + if (iicon == null) iconClear.gone() + iconExtra.setOnClickListener(onClick) + return iconExtra + } + + /** + * Icon for the rightmost ImageView, which typically contains a close icon + */ + var clearIcon: IIcon? = GoogleMaterial.Icon.gmd_clear + set(value) { + field = value + iconClear.setSearchIcon(value) + if (value == null) iconClear.gone() + } + /** + * Duration for the circular reveal animation + */ + var revealDuration: Long = 300L + /** + * Duration for the auto transition, which is namely used to resize the recycler view + */ + var transitionDuration: Long = 100L + /** + * Defines whether the edit text and mainAdapter should clear themselves when the searchView is closed + */ + var shouldClearOnClose: Boolean = false + /** + * Callback that will be called every time the searchView opens + */ + var openListener: ((searchView: SearchView) -> Unit)? = null + /** + * Callback that will be called every time the searchView closes + */ + var closeListener: ((searchView: SearchView) -> Unit)? = null + /** + * Draw a divider between the search bar and the suggestion items + * The divider is colored based on the [foregroundColor] + */ + var withDivider: Boolean = true + set(value) { + field = value + if (value) divider.visible() else divider.invisible() + } + /** + * Hint string to be set in the searchView + */ + var hintText: String? + get() = editText.hint?.toString() + set(value) { + editText.hint = value + } + /** + * Hint string res to be set in the searchView + */ + var hintTextRes: Int + @Deprecated(level = DeprecationLevel.ERROR, message = "Non readable property") + get() = nonReadable() + @StringRes set(value) { + hintText = context.string(value) + } + /** + * StringRes for a "no results found" item + * If [results] is ever set to an empty list, it will default to + * a list with one item with this string + * + * For simplicity, kau contains [R.string.kau_no_results_found] + * which you may use + */ + var noResultsFound: Int = -1 + /** + * Text watcher configurations on init + * By default, the observable is on a separate thread, so you may directly execute background processes + * This builder acts on an observable, so you may switch threads, debounce, and do anything else that you require + */ + var textObserver: (observable: Observable<String>, searchView: SearchView) -> Unit = { _, _ -> } + /** + * Click event for suggestion items + * This event is only triggered when [key] is not blank (like in [noResultsFound] + */ + var onItemClick: (position: Int, key: String, content: String, searchView: SearchView) -> Unit = { _, _, _, _ -> } + /** + * Long click event for suggestion items + * This event is only triggered when [key] is not blank (like in [noResultsFound] + */ + var onItemLongClick: (position: Int, key: String, content: String, searchView: SearchView) -> Unit = { _, _, _, _ -> } + /** + * If a [SearchItem]'s title contains the submitted query, make that portion bold + * See [SearchItem.withHighlights] + */ + var highlightQueryText: Boolean = true + } + + /** + * Contract for mainAdapter items + * Setting results will ensure that the values are sent on the UI thread + */ + var results: List<SearchItem> + get() = adapter.adapterItems + set(value) = context.runOnUiThread { + val list = if (value.isEmpty() && configs.noResultsFound > 0) + listOf(SearchItem("", context.string(configs.noResultsFound), iicon = null)) + else value + if (configs.highlightQueryText && value.isNotEmpty()) list.forEach { it.withHighlights(editText.text.toString()) } + cardTransition() + adapter.setNewList(list) + } + + /** + * Empties the list on the UI thread + * The noResults item will not be added + */ + internal fun clearResults() = context.runOnUiThread { cardTransition(); adapter.clear() } + + val configs = Configs() + //views + private val shadow: View by bindView(R.id.search_shadow) + 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<String> + private val progress: ProgressBar by bindView(R.id.search_progress) + val iconExtra: ImageView by bindView(R.id.search_extra) + private val iconClear: ImageView by bindView(R.id.search_clear) + private val divider: View by bindView(R.id.search_divider) + private val recycler: RecyclerView by bindView(R.id.search_recycler) + val adapter = FastItemAdapter<SearchItem>() + var menuItem: MenuItem? = null + val isOpen: Boolean + get() = card.isVisible() + + /* + * Ripple start points and search view offset + * These are calculated every time the search view is opened, + * and can be overridden with the open listener if necessary + */ + var menuX: Int = -1 //starting x for circular reveal + var menuY: Int = -1 //reference for cardview's marginTop + var menuHalfHeight: Int = -1 //starting y for circular reveal (relative to the cardview) + + init { + View.inflate(context, R.layout.kau_search_view, this) + z = 99f + iconNav.setSearchIcon(configs.navIcon).setOnClickListener { revealClose() } + iconClear.setSearchIcon(configs.clearIcon).setOnClickListener { editText.text.clear() } + tintForeground(configs.foregroundColor) + tintBackground(configs.backgroundColor) + with(recycler) { + isNestedScrollingEnabled = false + layoutManager = LinearLayoutManager(context) + addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { + super.onScrollStateChanged(recyclerView, newState) + if (newState == RecyclerView.SCROLL_STATE_DRAGGING) hideKeyboard() + } + }) + adapter = this@SearchView.adapter + itemAnimator = NoAnimator() + } + with(adapter) { + withSelectable(true) + withOnClickListener { _, _, item, position -> + if (item.key.isNotBlank()) configs.onItemClick(position, item.key, item.content, this@SearchView); true + } + withOnLongClickListener { _, _, item, position -> + if (item.key.isNotBlank()) configs.onItemLongClick(position, item.key, item.content, this@SearchView); true + } + } + textEvents = RxTextView.textChangeEvents(editText) + .skipInitialValue() + .observeOn(Schedulers.newThread()) + .map { it.text().toString().trim() } + textEvents.filter { it.isBlank() } + .subscribe { clearResults() } + } + + internal fun ImageView.setSearchIcon(iicon: IIcon?): ImageView { + setIcon(iicon, sizeDp = 18, color = configs.foregroundColor) + return this + } + + internal fun cardTransition(builder: AutoTransition.() -> Unit = {}) { + card.transitionAuto { duration = configs.transitionDuration; builder() } + } + + fun config(config: Configs.() -> Unit) { + configs.config() + } + + /** + * Binds the SearchView to a menu item and handles everything internally + * This is assuming that SearchView has already been added to a ViewGroup + * If not, see the extension function [bindSearchView] + */ + fun bind(menu: Menu, @IdRes id: Int, @ColorInt menuIconColor: Int = Color.WHITE, config: Configs.() -> Unit = {}): SearchView { + config(config) + configs.textObserver(textEvents.filter { it.isNotBlank() }, this) + menuItem = menu.findItem(id) + if (menuItem!!.icon == null) menuItem!!.icon = GoogleMaterial.Icon.gmd_search.toDrawable(context, 18, menuIconColor) + card.gone() + menuItem!!.setOnMenuItemClickListener { configureCoords(it); revealOpen(); true } + shadow.setOnClickListener { revealClose() } + return this + } + + fun unBind(replacementMenuItemClickListener: MenuItem.OnMenuItemClickListener? = null) { + parentViewGroup.removeView(this) + menuItem?.setOnMenuItemClickListener(replacementMenuItemClickListener) + } + + fun configureCoords(item: MenuItem) { + val view = parentViewGroup.findViewById<View>(item.itemId) ?: return + val locations = IntArray(2) + view.getLocationOnScreen(locations) + menuX = (locations[0] + view.width / 2) + menuHalfHeight = view.height / 2 + menuY = (locations[1] + menuHalfHeight) + card.viewTreeObserver.addOnPreDrawListener(object : ViewTreeObserver.OnPreDrawListener { + override fun onPreDraw(): Boolean { + view.viewTreeObserver.removeOnPreDrawListener(this) + val topAlignment = menuY - card.height / 2 + val params = (card.layoutParams as MarginLayoutParams).apply { + topMargin = topAlignment + } + card.layoutParams = params + return false + } + }) + } + + /** + * Handle a back press event + * Returns true if back press is consumed, false otherwise + */ + fun onBackPressed(): Boolean { + if (isOpen && menuItem != null) { + revealClose() + return true + } + return false + } + + /** + * Tint foreground attributes + * This can be done publicly through [configs], which will also save the color + */ + internal fun tintForeground(@ColorInt color: Int) { + iconNav.drawable.setTint(color) + iconClear.drawable.setTint(color) + divider.setBackgroundColor(color.adjustAlpha(0.1f)) + editText.tint(color) + editText.setTextColor(ColorStateList.valueOf(color)) + } + + /** + * Tint background attributes + * This can be done publicly through [configs], which will also save the color + */ + internal fun tintBackground(@ColorInt color: Int) { + card.setCardBackgroundColor(color) + } + + fun revealOpen() { + if (isOpen) return + /** + * The y component is relative to the cardView, but it hasn't been drawn yet so its own height is 0 + * We therefore use half the menuItem height, which is a close approximation to our intended value + * The cardView matches the parent's width, so menuX is correct + */ + configs.openListener?.invoke(this) + shadow.fadeIn() + editText.showKeyboard() + card.circularReveal(menuX, menuHalfHeight, duration = configs.revealDuration) { + cardTransition() + recycler.visible() + } + } + + fun revealClose() { + if (!isOpen) return + shadow.fadeOut(duration = configs.transitionDuration) + cardTransition { + addEndListener { + card.circularHide(menuX, menuHalfHeight, duration = configs.revealDuration, + onFinish = { + configs.closeListener?.invoke(this@SearchView) + if (configs.shouldClearOnClose) editText.text.clear() + recycler.gone() + }) + } + } + recycler.gone() + editText.hideKeyboard() + } +} + +@DslMarker +annotation class KauSearch + +/** + * Helper function that binds to an activity's main view + */ +@KauSearch +fun Activity.bindSearchView(menu: Menu, @IdRes id: Int, @ColorInt menuIconColor: Int = Color.WHITE, config: SearchView.Configs.() -> Unit = {}): SearchView + = findViewById<ViewGroup>(android.R.id.content).bindSearchView(menu, id, menuIconColor, config) + +/** + * Bind searchView to a menu item; call this in [Activity.onCreateOptionsMenu] + * Be wary that if you may reinflate the menu many times (eg through [Activity.invalidateOptionsMenu]), + * it may be worthwhile to hold a reference to the searchview and only bind it if it hasn't been bound before + */ +@KauSearch +fun ViewGroup.bindSearchView(menu: Menu, @IdRes id: Int, @ColorInt menuIconColor: Int = Color.WHITE, config: SearchView.Configs.() -> Unit = {}): SearchView { + val searchView = SearchView(context) + searchView.layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT) + addView(searchView) + searchView.bind(menu, id, menuIconColor, config) + return searchView +} + diff --git a/core/src/main/kotlin/ca/allanwang/kau/utils/ActivityUtils.kt b/core/src/main/kotlin/ca/allanwang/kau/utils/ActivityUtils.kt new file mode 100644 index 0000000..ec51bfd --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/utils/ActivityUtils.kt @@ -0,0 +1,71 @@ +package ca.allanwang.kau.utils + +import android.app.Activity +import android.content.Intent +import android.graphics.Color +import android.support.annotation.ColorInt +import android.support.annotation.StringRes +import android.support.design.widget.Snackbar +import android.support.v4.app.Fragment +import android.view.Menu +import ca.allanwang.kau.R +import com.mikepenz.iconics.typeface.IIcon +import org.jetbrains.anko.contentView +import org.jetbrains.anko.withArguments + +/** + * Created by Allan Wang on 2017-06-21. + */ + +/** + * Restarts an activity from itself without animations + * Keeps its existing extra bundles and has a builder to accept other parameters + */ +fun Activity.restart(builder: Intent.() -> Unit = {}) { + val i = Intent(this, this::class.java) + i.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION) + i.putExtras(intent.extras) + i.builder() + startActivity(i) + overridePendingTransition(0, 0) //No transitions + finish() + overridePendingTransition(0, 0) +} + +fun Activity.finishSlideOut() { + finish() + overridePendingTransition(R.anim.kau_fade_in, R.anim.kau_slide_out_right_top) +} + +var Activity.navigationBarColor: Int + get() = if (buildIsLollipopAndUp) window.navigationBarColor else Color.BLACK + set(value) { + if (buildIsLollipopAndUp) window.navigationBarColor = value + } + +var Activity.statusBarColor: Int + get() = if (buildIsLollipopAndUp) window.statusBarColor else Color.BLACK + set(value) { + if (buildIsLollipopAndUp) window.statusBarColor = value + } + +/** + * Themes the base menu icons and adds iicons programmatically based on ids + * + * Call in [Activity.onCreateOptionsMenu] + */ +fun Activity.setMenuIcons(menu: Menu, @ColorInt color: Int = Color.WHITE, vararg iicons: Pair<Int, IIcon>) { + iicons.forEach { (id, iicon) -> + menu.findItem(id).icon = iicon.toDrawable(this, sizeDp = 18, color = color) + } +} + +fun Activity.hideKeyboard() = currentFocus.hideKeyboard() + +fun Activity.showKeyboard() = currentFocus.showKeyboard() + +fun Activity.snackbar(text: String, duration: Int = Snackbar.LENGTH_LONG, builder: Snackbar.() -> Unit = {}) + = contentView!!.snackbar(text, duration, builder) + +fun Activity.snackbar(@StringRes textId: Int, duration: Int = Snackbar.LENGTH_LONG, builder: Snackbar.() -> Unit = {}) + = contentView!!.snackbar(textId, duration, builder)
\ No newline at end of file diff --git a/core/src/main/kotlin/ca/allanwang/kau/utils/AnimHolder.kt b/core/src/main/kotlin/ca/allanwang/kau/utils/AnimHolder.kt new file mode 100644 index 0000000..3db8b9c --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/utils/AnimHolder.kt @@ -0,0 +1,15 @@ +package ca.allanwang.kau.utils + +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) + val decelerateInterpolator = lazyInterpolator(android.R.interpolator.decelerate_cubic) + +}
\ No newline at end of file diff --git a/core/src/main/kotlin/ca/allanwang/kau/utils/AnimUtils.kt b/core/src/main/kotlin/ca/allanwang/kau/utils/AnimUtils.kt new file mode 100644 index 0000000..86b049e --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/utils/AnimUtils.kt @@ -0,0 +1,153 @@ +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. + * + * Animation extension @KauUtils functions for Views + */ +@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) { + v.removeOnLayoutChangeListener(this) + var x2 = x + var y2 = y + if (x2 > right) x2 = 0 + if (y2 > bottom) y2 = 0 + val radius = Math.hypot(Math.max(x2, right - x2).toDouble(), Math.max(y2, bottom - y2).toDouble()).toInt() + val reveal = ViewAnimationUtils.createCircularReveal(v, x2, y2, 0f, radius.toFloat()) + reveal.interpolator = DecelerateInterpolator(1f) + reveal.duration = duration + reveal.addListener(object : AnimatorListenerAdapter() { + override @KauUtils fun onAnimationStart(animation: Animator?) { + visible() + onStart?.invoke() + } + + override @KauUtils fun onAnimationEnd(animation: Animator?) = onFinish?.invoke() ?: Unit + override @KauUtils fun onAnimationCancel(animation: Animator?) = onFinish?.invoke() ?: Unit + }) + reveal.start() + } + }) +} + +@KauUtils fun View.circularReveal(x: Int = 0, y: Int = 0, offset: Long = 0L, radius: Float = -1.0f, duration: Long = 500L, onStart: (() -> Unit)? = null, onFinish: (() -> Unit)? = null) { + if (!isAttachedToWindow) { + onStart?.invoke() + visible() + onFinish?.invoke() + return + } + var r = radius + if (r < 0.0f) { + r = Math.max(Math.hypot(x.toDouble(), y.toDouble()), Math.hypot((width - x.toDouble()), (height - y.toDouble()))).toFloat() + } + val anim = ViewAnimationUtils.createCircularReveal(this, x, y, 0f, r).setDuration(duration) + anim.startDelay = offset + anim.addListener(object : AnimatorListenerAdapter() { + override @KauUtils fun onAnimationStart(animation: Animator?) { + visible() + onStart?.invoke() + } + + override @KauUtils fun onAnimationEnd(animation: Animator?) = onFinish?.invoke() ?: Unit + override @KauUtils fun onAnimationCancel(animation: Animator?) = onFinish?.invoke() ?: Unit + }) + anim.start() +} + +@KauUtils fun View.circularHide(x: Int = 0, y: Int = 0, offset: Long = 0L, radius: Float = -1.0f, duration: Long = 500L, onStart: (() -> Unit)? = null, onFinish: (() -> Unit)? = null) { + if (!isAttachedToWindow) { + onStart?.invoke() + invisible() + onFinish?.invoke() + return + } + var r = radius + if (r < 0.0f) { + r = Math.max(Math.hypot(x.toDouble(), y.toDouble()), Math.hypot((width - x.toDouble()), (height - y.toDouble()))).toFloat() + } + val anim = ViewAnimationUtils.createCircularReveal(this, x, y, r, 0f).setDuration(duration) + anim.startDelay = offset + anim.addListener(object : AnimatorListenerAdapter() { + override @KauUtils fun onAnimationStart(animation: Animator?) = onStart?.invoke() ?: Unit + + override @KauUtils fun onAnimationEnd(animation: Animator?) { + invisible() + onFinish?.invoke() ?: Unit + } + + override @KauUtils fun onAnimationCancel(animation: Animator?) = onFinish?.invoke() ?: Unit + }) + anim.start() +} + +@KauUtils fun View.fadeIn(offset: Long = 0L, duration: Long = 200L, onStart: (() -> Unit)? = null, onFinish: (() -> Unit)? = null) { + if (!isAttachedToWindow) { + onStart?.invoke() + visible() + onFinish?.invoke() + return + } + if (isAttachedToWindow) { + val anim = AnimationUtils.loadAnimation(context, android.R.anim.fade_in) + anim.startOffset = offset + anim.duration = duration + anim.setAnimationListener(object : Animation.AnimationListener { + override @KauUtils fun onAnimationRepeat(animation: Animation?) {} + override @KauUtils fun onAnimationEnd(animation: Animation?) = onFinish?.invoke() ?: Unit + override @KauUtils fun onAnimationStart(animation: Animation?) { + visible() + onStart?.invoke() + } + }) + startAnimation(anim) + } +} + +@KauUtils fun View.fadeOut(offset: Long = 0L, duration: Long = 200L, onStart: (() -> Unit)? = null, onFinish: (() -> Unit)? = null) { + if (!isAttachedToWindow) { + onStart?.invoke() + invisible() + onFinish?.invoke() + return + } + val anim = AnimationUtils.loadAnimation(context, android.R.anim.fade_out) + anim.startOffset = offset + anim.duration = duration + anim.setAnimationListener(object : Animation.AnimationListener { + override @KauUtils fun onAnimationRepeat(animation: Animation?) {} + override @KauUtils fun onAnimationEnd(animation: Animation?) { + invisible() + onFinish?.invoke() + } + + override @KauUtils fun onAnimationStart(animation: Animation?) { + onStart?.invoke() + } + }) + startAnimation(anim) +} + +@KauUtils fun TextView.setTextWithFade(text: String, duration: Long = 200, onFinish: (() -> Unit)? = null) { + fadeOut(duration = duration, onFinish = { + setText(text) + fadeIn(duration = duration, onFinish = 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/core/src/main/kotlin/ca/allanwang/kau/utils/ColorUtils.kt b/core/src/main/kotlin/ca/allanwang/kau/utils/ColorUtils.kt new file mode 100644 index 0000000..8590d6f --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/utils/ColorUtils.kt @@ -0,0 +1,235 @@ +package ca.allanwang.kau.utils + +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Color +import android.graphics.PorterDuff +import android.graphics.drawable.Drawable +import android.os.Build +import android.support.annotation.ColorInt +import android.support.annotation.FloatRange +import android.support.annotation.IntRange +import android.support.v4.content.ContextCompat +import android.support.v4.graphics.drawable.DrawableCompat +import android.support.v7.widget.AppCompatEditText +import android.support.v7.widget.Toolbar +import android.widget.* +import com.afollestad.materialdialogs.R + +/** + * Created by Allan Wang on 2017-06-08. + */ +fun Int.isColorDark(): Boolean + = (0.299 * Color.red(this) + 0.587 * Color.green(this) + 0.114 * Color.blue(this)) / 255.0 < 0.5 + +fun Int.toHexString(withAlpha: Boolean = false, withHexPrefix: Boolean = true): String { + val hex = if (withAlpha) String.format("#%08X", this) + else String.format("#%06X", 0xFFFFFF and this) + return if (withHexPrefix) hex else hex.substring(1) +} + +fun Int.toRgbaString(): String = "rgba(${Color.red(this)}, ${Color.green(this)}, ${Color.blue(this)}, ${(Color.alpha(this) / 255f).round(3)})" + +fun Int.toHSV(): FloatArray { + val hsv = FloatArray(3) + Color.colorToHSV(this, hsv) + return hsv +} + +fun FloatArray.toColor(): Int = Color.HSVToColor(this) + +fun Int.isColorVisibleOn(@ColorInt color: Int, @IntRange(from = 0L, to = 255L) delta: Int = 25, + @IntRange(from = 0L, to = 255L) minAlpha: Int = 50): Boolean = + if (Color.alpha(this) < minAlpha) false + else !(Math.abs(Color.red(this) - Color.red(color)) < delta + && Math.abs(Color.green(this) - Color.green(color)) < delta + && Math.abs(Color.blue(this) - Color.blue(color)) < delta) + + +@ColorInt +fun Context.getDisabledColor(): Int { + val primaryColor = resolveColor(android.R.attr.textColorPrimary) + val disabledColor = if (primaryColor.isColorDark()) Color.BLACK else Color.WHITE + return disabledColor.adjustAlpha(0.3f) +} + +@ColorInt +fun Int.adjustAlpha(factor: Float): Int { + val alpha = Math.round(Color.alpha(this) * factor) + return Color.argb(alpha, Color.red(this), Color.green(this), Color.blue(this)) +} + +val Int.isColorTransparent: Boolean + get() = Color.alpha(this) != 255 + +@ColorInt +fun Int.withAlpha(@IntRange(from = 0L, to = 255L) alpha: Int): Int + = Color.argb(alpha, Color.red(this), Color.green(this), Color.blue(this)) + +@ColorInt +fun Int.withMinAlpha(@IntRange(from = 0L, to = 255L) alpha: Int): Int + = Color.argb(Math.max(alpha, Color.alpha(this)), Color.red(this), Color.green(this), Color.blue(this)) + +@ColorInt +fun Int.lighten(@FloatRange(from = 0.0, to = 1.0) factor: Float = 0.1f): Int { + val (red, green, blue) = intArrayOf(Color.red(this), Color.green(this), Color.blue(this)) + .map { (it * (1f - factor) + 255f * factor).toInt() } + return Color.argb(Color.alpha(this), red, green, blue) +} + +@ColorInt +fun Int.darken(@FloatRange(from = 0.0, to = 1.0) factor: Float = 0.1f): Int { + val (red, green, blue) = intArrayOf(Color.red(this), Color.green(this), Color.blue(this)) + .map { (it * (1f - factor)).toInt() } + return Color.argb(Color.alpha(this), red, green, blue) +} + +@ColorInt +fun Int.colorToBackground(@FloatRange(from = 0.0, to = 1.0) factor: Float = 0.1f): Int + = if (isColorDark()) darken(factor) else lighten(factor) + +@ColorInt +fun Int.colorToForeground(@FloatRange(from = 0.0, to = 1.0) factor: Float = 0.1f): Int + = if (isColorDark()) lighten(factor) else darken(factor) + +@Throws(IllegalArgumentException::class) +fun String.toColor(): Int { + val toParse: String + if (startsWith("#") && length == 4) + toParse = "#${this[1]}${this[1]}${this[2]}${this[2]}${this[3]}${this[3]}" + else + toParse = this + return Color.parseColor(toParse) +} + +//Get ColorStateList +fun Context.colorStateList(@ColorInt color: Int): ColorStateList { + val disabledColor = color.adjustAlpha(0.3f) + return ColorStateList(arrayOf(intArrayOf(android.R.attr.state_enabled, -android.R.attr.state_checked), + intArrayOf(android.R.attr.state_enabled, android.R.attr.state_checked), + intArrayOf(-android.R.attr.state_enabled, -android.R.attr.state_checked), + intArrayOf(-android.R.attr.state_enabled, android.R.attr.state_checked)), + intArrayOf(color.adjustAlpha(0.8f), color, disabledColor, disabledColor)) +} + +/* + * Tint Helpers + * Kotlin tint bindings that start with 'tint' so it doesn't conflict with existing methods + * Largely based on MDTintHelper + * https://github.com/afollestad/material-dialogs/blob/master/core/src/main/java/com/afollestad/materialdialogs/internal/MDTintHelper.java + */ +fun RadioButton.tint(colors: ColorStateList) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + buttonTintList = colors + } else { + val radioDrawable = context.drawable(R.drawable.abc_btn_radio_material) + val d = DrawableCompat.wrap(radioDrawable) + DrawableCompat.setTintList(d, colors) + buttonDrawable = d + } +} + +fun RadioButton.tint(@ColorInt color: Int) = tint(context.colorStateList(color)) + +fun CheckBox.tint(colors: ColorStateList) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + buttonTintList = colors + } else { + val checkDrawable = context.drawable(R.drawable.abc_btn_check_material) + val drawable = DrawableCompat.wrap(checkDrawable) + DrawableCompat.setTintList(drawable, colors) + buttonDrawable = drawable + } +} + +fun CheckBox.tint(@ColorInt color: Int) = tint(context.colorStateList(color)) + +fun SeekBar.tint(@ColorInt color: Int) { + val s1 = ColorStateList.valueOf(color) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + thumbTintList = s1 + progressTintList = s1 + } else if (Build.VERSION.SDK_INT > Build.VERSION_CODES.GINGERBREAD_MR1) { + val progressDrawable = DrawableCompat.wrap(progressDrawable) + this.progressDrawable = progressDrawable + DrawableCompat.setTintList(progressDrawable, s1) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + val thumbDrawable = DrawableCompat.wrap(thumb) + DrawableCompat.setTintList(thumbDrawable, s1) + thumb = thumbDrawable + } + } else { + val mode: PorterDuff.Mode = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.GINGERBREAD_MR1) + PorterDuff.Mode.MULTIPLY else PorterDuff.Mode.SRC_IN + indeterminateDrawable?.setColorFilter(color, mode) + progressDrawable?.setColorFilter(color, mode) + } +} + +fun ProgressBar.tint(@ColorInt color: Int, skipIndeterminate: Boolean = false) { + val sl = ColorStateList.valueOf(color) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + progressTintList = sl + secondaryProgressTintList = sl + if (!skipIndeterminate) indeterminateTintList = sl + } else { + val mode: PorterDuff.Mode = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.GINGERBREAD_MR1) + PorterDuff.Mode.MULTIPLY else PorterDuff.Mode.SRC_IN + indeterminateDrawable?.setColorFilter(color, mode) + progressDrawable?.setColorFilter(color, mode) + } +} + +fun Context.textColorStateList(@ColorInt color: Int): ColorStateList { + val states = arrayOf( + intArrayOf(-android.R.attr.state_enabled), + intArrayOf(-android.R.attr.state_pressed, -android.R.attr.state_focused), + intArrayOf() + ) + val colors = intArrayOf( + resolveColor(R.attr.colorControlNormal), + resolveColor(R.attr.colorControlNormal), + color + ) + return ColorStateList(states, colors) +} + +fun EditText.tint(@ColorInt color: Int) { + val editTextColorStateList = context.textColorStateList(color) + if (this is AppCompatEditText) { + supportBackgroundTintList = editTextColorStateList + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + backgroundTintList = editTextColorStateList + } + tintCursor(color) +} + +fun EditText.tintCursor(@ColorInt color: Int) { + try { + val fCursorDrawableRes = TextView::class.java.getDeclaredField("mCursorDrawableRes") + fCursorDrawableRes.isAccessible = true + val mCursorDrawableRes = fCursorDrawableRes.getInt(this) + val fEditor = TextView::class.java.getDeclaredField("mEditor") + fEditor.isAccessible = true + val editor = fEditor.get(this) + val clazz = editor.javaClass + val fCursorDrawable = clazz.getDeclaredField("mCursorDrawable") + fCursorDrawable.isAccessible = true + val drawables: Array<Drawable> = Array(2, { + val drawable = ContextCompat.getDrawable(context, mCursorDrawableRes) + drawable.setColorFilter(color, PorterDuff.Mode.SRC_IN) + drawable + }) + fCursorDrawable.set(editor, drawables) + } catch (e: Exception) { + e.printStackTrace() + } +} + +fun Toolbar.tint(@ColorInt color: Int, tintTitle: Boolean = true) { + if (tintTitle) { + setTitleTextColor(color) + setSubtitleTextColor(color) + } + (0 until childCount).asSequence().forEach { (getChildAt(it) as? ImageButton)?.setColorFilter(color) } +}
\ No newline at end of file diff --git a/core/src/main/kotlin/ca/allanwang/kau/utils/Const.kt b/core/src/main/kotlin/ca/allanwang/kau/utils/Const.kt new file mode 100644 index 0000000..944caa4 --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/utils/Const.kt @@ -0,0 +1,6 @@ +package ca.allanwang.kau.utils + +/** + * Created by Allan Wang on 2017-06-08. + */ +const val ANDROID_NAMESPACE = "http://schemas.android.com/apk/res/android"
\ No newline at end of file diff --git a/core/src/main/kotlin/ca/allanwang/kau/utils/ContextUtils.kt b/core/src/main/kotlin/ca/allanwang/kau/utils/ContextUtils.kt new file mode 100644 index 0000000..21021e2 --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/utils/ContextUtils.kt @@ -0,0 +1,165 @@ +package ca.allanwang.kau.utils + +import android.app.Activity +import android.app.ActivityOptions +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.drawable.Drawable +import android.net.ConnectivityManager +import android.net.Uri +import android.os.Bundle +import android.support.annotation.* +import android.support.v4.app.ActivityOptionsCompat +import android.support.v4.content.ContextCompat +import android.util.TypedValue +import android.view.View +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<out Activity>, + 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() + 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() +} + +/** + * Bring in activity from the right + */ +fun Context.startActivitySlideIn(clazz: Class<out Activity>, 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 = intentBuilder, bundle = bundle) +} + +/** + * Bring in activity from behind while pushing the current activity to the right + * This replicates the exit animation of a sliding activity, but is a forward creation + * For the animation to work, the previous activity should not be in the stack (otherwise you wouldn't need this in the first place) + * Consequently, the stack will be cleared by default + */ +fun Context.startActivitySlideOut(clazz: Class<out Activity>, 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 = intentBuilder, bundle = bundle) +} + +fun Context.startPlayStoreLink(@StringRes packageIdRes: Int) = startPlayStoreLink(string(packageIdRes)) + +fun Context.startPlayStoreLink(packageId: String) { + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://play.google.com/store/apps/details?id=$packageId"))) +} + +/** + * Starts a url + * If given a series of links, will open the first one that isn't null + */ +fun Context.startLink(vararg url: String?) { + val link = url.firstOrNull { !it.isNullOrBlank() } ?: return + val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(link)) + startActivity(browserIntent) +} + +//Toast helpers +fun Context.toast(@StringRes id: Int, duration: Int = Toast.LENGTH_LONG) = toast(this.string(id), duration) + +fun Context.toast(text: String, duration: Int = Toast.LENGTH_LONG) { + Toast.makeText(this, text, duration).show() +} + +//Resource retrievers +fun Context.string(@StringRes id: Int): String = getString(id) + +fun Context.string(@StringRes id: Int, fallback: String?): String? = if (id > 0) string(id) else fallback +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) +fun Context.drawable(@DrawableRes id: Int, fallback: Drawable?): Drawable? = if (id > 0) drawable(id) else fallback + +//Attr retrievers +fun Context.resolveColor(@AttrRes attr: Int, fallback: Int = 0): Int { + val a = theme.obtainStyledAttributes(intArrayOf(attr)) + try { + return a.getColor(0, fallback) + } finally { + a.recycle() + } +} + +fun Context.resolveDrawable(@AttrRes attr: Int): Drawable? { + val a = theme.obtainStyledAttributes(intArrayOf(attr)) + try { + return a.getDrawable(0) + } finally { + a.recycle() + } +} + +fun Context.resolveBoolean(@AttrRes attr: Int, fallback: Boolean = false): Boolean { + val a = theme.obtainStyledAttributes(intArrayOf(attr)) + try { + return a.getBoolean(0, fallback) + } finally { + a.recycle() + } +} + +fun Context.resolveString(@AttrRes attr: Int, fallback: String = ""): String { + val v = TypedValue() + return if (theme.resolveAttribute(attr, v, true)) v.string.toString() else fallback +} + +/** + * Wrapper function for the MaterialDialog adapterBuilder + * There is no need to call build() or show() as those are done by default + */ +inline fun Context.materialDialog(action: MaterialDialog.Builder.() -> Unit): MaterialDialog { + val builder = MaterialDialog.Builder(this) + builder.action() + return builder.show() +} + +inline val Context.isNetworkAvailable: Boolean + get() { + val connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val activeNetworkInfo = connectivityManager.activeNetworkInfo + return activeNetworkInfo != null && activeNetworkInfo.isConnectedOrConnecting + } + +fun Context.getDip(value: Float): Float = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, value, resources.displayMetrics) + +inline 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. + */ +inline 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 + } + +fun Context.hasPermission(permissions: String) = !buildIsMarshmallowAndUp || ContextCompat.checkSelfPermission(this, permissions) == PackageManager.PERMISSION_GRANTED
\ No newline at end of file diff --git a/core/src/main/kotlin/ca/allanwang/kau/utils/Either.kt b/core/src/main/kotlin/ca/allanwang/kau/utils/Either.kt new file mode 100644 index 0000000..dab5810 --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/utils/Either.kt @@ -0,0 +1,32 @@ +package ca.allanwang.kau.utils + +/** + * Created by Allan Wang on 2017-06-17. + * + * Courtesy of adelnizamutdinov + * + * https://github.com/adelnizamutdinov/kotlin-either + */ +@Suppress("unused") +sealed class Either<out L, out R> + +data class Left<out T>(val value: T) : Either<T, Nothing>() +data class Right<out T>(val value: T) : Either<Nothing, T>() + +inline fun <L, R, T> Either<L, R>.fold(left: (L) -> T, right: (R) -> T): T = + when (this) { + is Left -> left(value) + is Right -> right(value) + } + +inline fun <L, R, T> Either<L, R>.flatMap(f: (R) -> Either<L, T>): Either<L, T> = + fold({ this as Left }, f) + +inline fun <L, R, T> Either<L, R>.map(f: (R) -> T): Either<L, T> = + flatMap { Right(f(it)) } + +val <T> Either<T, *>.isLeft: Boolean + get() = this is Left<T> + +val <T> Either<*, T>.isRight: Boolean + get() = this is Right<T>
\ No newline at end of file diff --git a/core/src/main/kotlin/ca/allanwang/kau/utils/FontUtils.kt b/core/src/main/kotlin/ca/allanwang/kau/utils/FontUtils.kt new file mode 100644 index 0000000..3fc509d --- /dev/null +++ b/core/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<String, Typeface> = 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/core/src/main/kotlin/ca/allanwang/kau/utils/FragmentUtils.kt b/core/src/main/kotlin/ca/allanwang/kau/utils/FragmentUtils.kt new file mode 100644 index 0000000..acc71f2 --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/utils/FragmentUtils.kt @@ -0,0 +1,12 @@ +package ca.allanwang.kau.utils + +import android.support.v4.app.Fragment +import org.jetbrains.anko.bundleOf + +/** + * Created by Allan Wang on 2017-07-02. + */ +fun <T : Fragment> T.withArguments(vararg params: Pair<String, Any>): T { + arguments = bundleOf(*params) + return this +}
\ No newline at end of file diff --git a/core/src/main/kotlin/ca/allanwang/kau/utils/IIconUtils.kt b/core/src/main/kotlin/ca/allanwang/kau/utils/IIconUtils.kt new file mode 100644 index 0000000..03a1605 --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/utils/IIconUtils.kt @@ -0,0 +1,20 @@ +package ca.allanwang.kau.utils + +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Color +import android.graphics.drawable.Drawable +import android.support.annotation.ColorInt +import com.mikepenz.iconics.IconicsDrawable +import com.mikepenz.iconics.typeface.IIcon + +/** + * Created by Allan Wang on 2017-05-29. + */ +@KauUtils fun IIcon.toDrawable(c: Context, sizeDp: Int = 24, @ColorInt color: Int = Color.WHITE, builder: IconicsDrawable.() -> Unit = {}): Drawable { + val state = ColorStateList.valueOf(color) + val icon = IconicsDrawable(c).icon(this).sizeDp(sizeDp) + icon.setTintList(state) + icon.builder() + return icon +}
\ No newline at end of file diff --git a/core/src/main/kotlin/ca/allanwang/kau/utils/Kotterknife.kt b/core/src/main/kotlin/ca/allanwang/kau/utils/Kotterknife.kt new file mode 100644 index 0000000..247bbc7 --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/utils/Kotterknife.kt @@ -0,0 +1,166 @@ +package ca.allanwang.kau.utils + +/** + * Created by Allan Wang on 2017-05-29. + * + * Courtesy of Jake Wharton + * + * https://github.com/JakeWharton/kotterknife/blob/master/src/main/kotlin/kotterknife/ButterKnife.kt + */ +import android.app.Activity +import android.app.Dialog +import android.app.DialogFragment +import android.app.Fragment +import android.support.v7.widget.RecyclerView.ViewHolder +import android.view.View +import kotlin.properties.ReadOnlyProperty +import kotlin.reflect.KProperty +import android.support.v4.app.DialogFragment as SupportDialogFragment +import android.support.v4.app.Fragment as SupportFragment + +fun <V : View> View.bindView(id: Int) + : ReadOnlyProperty<View, V> = required(id, viewFinder) + +fun <V : View> Activity.bindView(id: Int) + : ReadOnlyProperty<Activity, V> = required(id, viewFinder) + +fun <V : View> Dialog.bindView(id: Int) + : ReadOnlyProperty<Dialog, V> = required(id, viewFinder) + +fun <V : View> DialogFragment.bindView(id: Int) + : ReadOnlyProperty<DialogFragment, V> = required(id, viewFinder) + +fun <V : View> android.support.v4.app.DialogFragment.bindView(id: Int) + : ReadOnlyProperty<android.support.v4.app.DialogFragment, V> = required(id, viewFinder) + +fun <V : View> Fragment.bindView(id: Int) + : ReadOnlyProperty<Fragment, V> = required(id, viewFinder) + +fun <V : View> android.support.v4.app.Fragment.bindView(id: Int) + : ReadOnlyProperty<android.support.v4.app.Fragment, V> = required(id, viewFinder) + +fun <V : View> ViewHolder.bindView(id: Int) + : ReadOnlyProperty<ViewHolder, V> = required(id, viewFinder) + +fun <V : View> View.bindOptionalView(id: Int) + : ReadOnlyProperty<View, V?> = optional(id, viewFinder) + +fun <V : View> Activity.bindOptionalView(id: Int) + : ReadOnlyProperty<Activity, V?> = optional(id, viewFinder) + +fun <V : View> Dialog.bindOptionalView(id: Int) + : ReadOnlyProperty<Dialog, V?> = optional(id, viewFinder) + +fun <V : View> DialogFragment.bindOptionalView(id: Int) + : ReadOnlyProperty<DialogFragment, V?> = optional(id, viewFinder) + +fun <V : View> android.support.v4.app.DialogFragment.bindOptionalView(id: Int) + : ReadOnlyProperty<android.support.v4.app.DialogFragment, V?> = optional(id, viewFinder) + +fun <V : View> Fragment.bindOptionalView(id: Int) + : ReadOnlyProperty<Fragment, V?> = optional(id, viewFinder) + +fun <V : View> android.support.v4.app.Fragment.bindOptionalView(id: Int) + : ReadOnlyProperty<android.support.v4.app.Fragment, V?> = optional(id, viewFinder) + +fun <V : View> ViewHolder.bindOptionalView(id: Int) + : ReadOnlyProperty<ViewHolder, V?> = optional(id, viewFinder) + +fun <V : View> View.bindViews(vararg ids: Int) + : ReadOnlyProperty<View, List<V>> = required(ids, viewFinder) + +fun <V : View> Activity.bindViews(vararg ids: Int) + : ReadOnlyProperty<Activity, List<V>> = required(ids, viewFinder) + +fun <V : View> Dialog.bindViews(vararg ids: Int) + : ReadOnlyProperty<Dialog, List<V>> = required(ids, viewFinder) + +fun <V : View> DialogFragment.bindViews(vararg ids: Int) + : ReadOnlyProperty<DialogFragment, List<V>> = required(ids, viewFinder) + +fun <V : View> android.support.v4.app.DialogFragment.bindViews(vararg ids: Int) + : ReadOnlyProperty<android.support.v4.app.DialogFragment, List<V>> = required(ids, viewFinder) + +fun <V : View> Fragment.bindViews(vararg ids: Int) + : ReadOnlyProperty<Fragment, List<V>> = required(ids, viewFinder) + +fun <V : View> android.support.v4.app.Fragment.bindViews(vararg ids: Int) + : ReadOnlyProperty<android.support.v4.app.Fragment, List<V>> = required(ids, viewFinder) + +fun <V : View> ViewHolder.bindViews(vararg ids: Int) + : ReadOnlyProperty<ViewHolder, List<V>> = required(ids, viewFinder) + +fun <V : View> View.bindOptionalViews(vararg ids: Int) + : ReadOnlyProperty<View, List<V>> = optional(ids, viewFinder) + +fun <V : View> Activity.bindOptionalViews(vararg ids: Int) + : ReadOnlyProperty<Activity, List<V>> = optional(ids, viewFinder) + +fun <V : View> Dialog.bindOptionalViews(vararg ids: Int) + : ReadOnlyProperty<Dialog, List<V>> = optional(ids, viewFinder) + +fun <V : View> DialogFragment.bindOptionalViews(vararg ids: Int) + : ReadOnlyProperty<DialogFragment, List<V>> = optional(ids, viewFinder) + +fun <V : View> android.support.v4.app.DialogFragment.bindOptionalViews(vararg ids: Int) + : ReadOnlyProperty<android.support.v4.app.DialogFragment, List<V>> = optional(ids, viewFinder) + +fun <V : View> Fragment.bindOptionalViews(vararg ids: Int) + : ReadOnlyProperty<Fragment, List<V>> = optional(ids, viewFinder) + +fun <V : View> android.support.v4.app.Fragment.bindOptionalViews(vararg ids: Int) + : ReadOnlyProperty<android.support.v4.app.Fragment, List<V>> = optional(ids, viewFinder) + +fun <V : View> ViewHolder.bindOptionalViews(vararg ids: Int) + : ReadOnlyProperty<ViewHolder, List<V>> = optional(ids, viewFinder) + +private val View.viewFinder: View.(Int) -> View? + get() = { findViewById(it) } +private val Activity.viewFinder: Activity.(Int) -> View? + get() = { findViewById(it) } +private val Dialog.viewFinder: Dialog.(Int) -> View? + get() = { findViewById(it) } +private val DialogFragment.viewFinder: DialogFragment.(Int) -> View? + get() = { dialog.findViewById(it) } +private val android.support.v4.app.DialogFragment.viewFinder: android.support.v4.app.DialogFragment.(Int) -> View? + get() = { dialog.findViewById(it) } +private val Fragment.viewFinder: Fragment.(Int) -> View? + get() = { view.findViewById(it) } +private val android.support.v4.app.Fragment.viewFinder: android.support.v4.app.Fragment.(Int) -> View? + get() = { view!!.findViewById(it) } +private val ViewHolder.viewFinder: ViewHolder.(Int) -> View? + get() = { itemView.findViewById(it) } + +private fun viewNotFound(id: Int, desc: KProperty<*>): Nothing = + throw IllegalStateException("View ID $id for '${desc.name}' not found.") + +@Suppress("UNCHECKED_CAST") +private fun <T, V : View> required(id: Int, finder: T.(Int) -> View?) + = Lazy { t: T, desc -> (t.finder(id) as V?)?.apply { } ?: viewNotFound(id, desc) } + +@Suppress("UNCHECKED_CAST") +private fun <T, V : View> optional(id: Int, finder: T.(Int) -> View?) + = Lazy { t: T, _ -> t.finder(id) as V? } + +@Suppress("UNCHECKED_CAST") +private fun <T, V : View> required(ids: IntArray, finder: T.(Int) -> View?) + = Lazy { t: T, desc -> ids.map { t.finder(it) as V? ?: viewNotFound(it, desc) } } + +@Suppress("UNCHECKED_CAST") +private fun <T, V : View> optional(ids: IntArray, finder: T.(Int) -> View?) + = Lazy { t: T, _ -> ids.map { t.finder(it) as V? }.filterNotNull() } + +// Like Kotlin's lazy delegate but the initializer gets the target and metadata passed to it +private class Lazy<T, V>(private val initializer: (T, KProperty<*>) -> V) : ReadOnlyProperty<T, V> { + private object EMPTY + + private var value: Any? = EMPTY + + override fun getValue(thisRef: T, property: KProperty<*>): V { + if (value == EMPTY) { + value = initializer(thisRef, property) + } + @Suppress("UNCHECKED_CAST") + return value as V + } +}
\ No newline at end of file diff --git a/core/src/main/kotlin/ca/allanwang/kau/utils/PackageUtils.kt b/core/src/main/kotlin/ca/allanwang/kau/utils/PackageUtils.kt new file mode 100644 index 0000000..837c209 --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/utils/PackageUtils.kt @@ -0,0 +1,48 @@ +package ca.allanwang.kau.utils + +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.support.annotation.RequiresApi + +/** + * Created by Allan Wang on 2017-06-23. + */ + +/** + * Checks if a given package is installed + * @param packageName packageId + * @return true if installed with activity, false otherwise + */ +@KauUtils fun Context.isAppInstalled(packageName: String): Boolean { + val pm = packageManager + var installed: Boolean + try { + pm.getPackageInfo(packageName, PackageManager.GET_ACTIVITIES) + installed = true + } catch (e: PackageManager.NameNotFoundException) { + installed = false + } + return installed +} + +val buildIsLollipopAndUp: Boolean + get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP + +val buildIsMarshmallowAndUp: Boolean + get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M + +val buildIsNougatAndUp: Boolean + get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N + +const val INSTALLER_GOOGLE_PLAY_VENDING = "com.android.vending" +const val INSTALLER_GOOGLE_PLAY_FEEDBACK = "com.google.android.feedback" + +val Context.installerPackageName: String? + get() = packageManager.getInstallerPackageName(packageName) + +val Context.isFromGooglePlay: Boolean + get() { + val installer = installerPackageName + return arrayOf(INSTALLER_GOOGLE_PLAY_FEEDBACK, INSTALLER_GOOGLE_PLAY_VENDING).any { it == installer } + }
\ No newline at end of file diff --git a/core/src/main/kotlin/ca/allanwang/kau/utils/StringHolder.kt b/core/src/main/kotlin/ca/allanwang/kau/utils/StringHolder.kt new file mode 100644 index 0000000..e70a2d1 --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/utils/StringHolder.kt @@ -0,0 +1,22 @@ +package ca.allanwang.kau.utils + +import android.content.Context +import android.support.annotation.StringRes + +/** + * Created by Allan Wang on 2017-06-08. + */ +class StringHolder { + var text: String? = null + var textRes: Int = 0 + + constructor(@StringRes textRes: Int) { + this.textRes = textRes + } + + constructor(text: String) { + this.text = text + } + + fun getString(context: Context) = context.string(textRes, text) +}
\ No newline at end of file diff --git a/core/src/main/kotlin/ca/allanwang/kau/utils/TransitionUtils.kt b/core/src/main/kotlin/ca/allanwang/kau/utils/TransitionUtils.kt new file mode 100644 index 0000000..9e668d0 --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/utils/TransitionUtils.kt @@ -0,0 +1,19 @@ +package ca.allanwang.kau.utils + +import android.support.transition.Transition +import android.support.transition.TransitionSet + +/** + * Created by Allan Wang on 2017-06-24. + */ +class TransitionEndListener(val onEnd: (transition: Transition) -> Unit) : Transition.TransitionListener { + override fun onTransitionEnd(transition: Transition) = onEnd(transition) + override fun onTransitionResume(transition: Transition) {} + override fun onTransitionPause(transition: Transition) {} + override fun onTransitionCancel(transition: Transition) {} + override fun onTransitionStart(transition: Transition) {} +} + +@KauUtils fun TransitionSet.addEndListener(onEnd: (transition: Transition) -> Unit) { + addListener(TransitionEndListener(onEnd)) +}
\ No newline at end of file diff --git a/core/src/main/kotlin/ca/allanwang/kau/utils/Utils.kt b/core/src/main/kotlin/ca/allanwang/kau/utils/Utils.kt new file mode 100644 index 0000000..84794f9 --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/utils/Utils.kt @@ -0,0 +1,111 @@ +package ca.allanwang.kau.utils + +import android.content.Context +import android.content.res.Resources +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.os.Handler +import android.os.Looper +import android.support.annotation.IntRange +import ca.allanwang.kau.R +import ca.allanwang.kau.logging.KL +import java.math.RoundingMode +import java.text.DecimalFormat + + +/** + * Created by Allan Wang on 2017-05-28. + */ + +/** + * Markers to isolate respective extension @KauUtils functions to their extended class + * Avoids having a whole bunch of methods for nested calls + */ +@DslMarker +annotation class KauUtils + +@KauUtils val Int.dpToPx: Int + get() = (this * Resources.getSystem().displayMetrics.density).toInt() + +@KauUtils val Int.pxToDp: Int + get() = (this / Resources.getSystem().displayMetrics.density).toInt() + +/** + * Log whether current state is in the main thread + */ +@KauUtils fun checkThread(id: Int) { + val status = if (Looper.myLooper() == Looper.getMainLooper()) "is" else "is not" + KL.d("$id $status in the main thread") +} + +/** + * Converts minute value to string + * Whole hours and days will be converted as such, otherwise it will default to x minutes + */ +@KauUtils fun Context.minuteToText(minutes: Long): String = with(minutes) { + if (this < 0L) string(R.string.kau_none) + else if (this == 60L) string(R.string.kau_one_hour) + else if (this == 1440L) string(R.string.kau_one_day) + else if (this % 1440L == 0L) String.format(string(R.string.kau_x_days), this / 1440L) + else if (this % 60L == 0L) String.format(string(R.string.kau_x_hours), this / 60L) + else String.format(string(R.string.kau_x_minutes), this) +} + +@KauUtils fun Number.round(@IntRange(from = 1L) decimalCount: Int): String { + val expression = StringBuilder().append("#.") + (1..decimalCount).forEach { expression.append("#") } + val formatter = DecimalFormat(expression.toString()) + formatter.roundingMode = RoundingMode.HALF_UP + return formatter.format(this) +} + +/** + * Extracts the bitmap of a drawable, and applies a scale if given + * For solid colors, a 1 x 1 pixel will be generated + */ +@KauUtils fun Drawable.toBitmap(scaling: Float = 1f, config: Bitmap.Config = Bitmap.Config.ARGB_8888): Bitmap { + if (this is BitmapDrawable && bitmap != null) { + if (scaling == 1f) return bitmap + val width = (bitmap.width * scaling).toInt() + val height = (bitmap.height * scaling).toInt() + return Bitmap.createScaledBitmap(bitmap, width, height, false) + } + val bitmap = if (intrinsicWidth <= 0 || intrinsicHeight <= 0) + Bitmap.createBitmap(1, 1, config) + else + Bitmap.createBitmap((intrinsicWidth * scaling).toInt(), (intrinsicHeight * scaling).toInt(), config) + val canvas = Canvas(bitmap) + setBounds(0, 0, canvas.width, canvas.height) + draw(canvas) + return bitmap +} + +/** + * Use block for autocloseables + */ +inline fun <T : AutoCloseable, R> T.use(block: (T) -> R): R { + var closed = false + try { + return block(this) + } catch (e: Exception) { + closed = true + try { + close() + } catch (closeException: Exception) { + e.addSuppressed(closeException) + } + throw e + } finally { + if (!closed) { + close() + } + } +} + +fun postDelayed(delay: Long, action: () -> Unit) { + Handler().postDelayed(action, delay) +} + +class KauException(message: String) : RuntimeException(message)
\ No newline at end of file diff --git a/core/src/main/kotlin/ca/allanwang/kau/utils/ViewUtils.kt b/core/src/main/kotlin/ca/allanwang/kau/utils/ViewUtils.kt new file mode 100644 index 0000000..b4752a5 --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/utils/ViewUtils.kt @@ -0,0 +1,126 @@ +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.annotation.TransitionRes +import android.support.design.widget.Snackbar +import android.support.transition.AutoTransition +import android.support.transition.Transition +import android.support.transition.TransitionInflater +import android.support.transition.TransitionManager +import android.support.v7.widget.LinearLayoutManager +import android.support.v7.widget.RecyclerView +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 + + +/** + * Created by Allan Wang on 2017-05-31. + */ +@KauUtils fun <T : View> T.visible(): T { + visibility = View.VISIBLE + return this +} + +@KauUtils fun <T : View> T.invisible(): T { + visibility = View.INVISIBLE + return this +} + +@KauUtils fun <T : View> T.gone(): T { + visibility = View.GONE + return this +} + +@KauUtils fun View.isVisible(): Boolean = visibility == View.VISIBLE +@KauUtils fun View.isInvisible(): Boolean = visibility == View.INVISIBLE +@KauUtils fun View.isGone(): Boolean = visibility == View.GONE + +fun View.snackbar(text: String, duration: Int = Snackbar.LENGTH_LONG, builder: Snackbar.() -> Unit = {}): Snackbar { + val snackbar = Snackbar.make(this, text, duration) + snackbar.builder() + snackbar.show() + return snackbar +} + +fun View.snackbar(@StringRes textId: Int, duration: Int = Snackbar.LENGTH_LONG, builder: Snackbar.() -> Unit = {}) + = snackbar(context.string(textId), duration, builder) + +@KauUtils fun TextView.setTextIfValid(@StringRes id: Int) { + if (id > 0) text = context.string(id) +} + +@KauUtils fun ImageView.setIcon(icon: IIcon?, sizeDp: Int = 24, @ColorInt color: Int = Color.WHITE, builder: IconicsDrawable.() -> Unit = {}) { + if (icon == null) return + setImageDrawable(icon.toDrawable(context, sizeDp = sizeDp, color = color, builder = builder)) +} + +@KauUtils fun View.hideKeyboard() { + clearFocus() + (context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).hideSoftInputFromWindow(windowToken, 0) +} + +@KauUtils fun View.showKeyboard() { + requestFocus() + (context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).showSoftInput(this, InputMethodManager.SHOW_IMPLICIT) +} + +@KauUtils fun ViewGroup.transitionAuto(builder: AutoTransition.() -> Unit = {}) { + val transition = AutoTransition() + transition.builder() + TransitionManager.beginDelayedTransition(this, transition) +} + +@KauUtils fun ViewGroup.transitionDelayed(@TransitionRes id: Int, builder: Transition.() -> Unit = {}) { + val transition = TransitionInflater.from(context).inflateTransition(id) + transition.builder() + TransitionManager.beginDelayedTransition(this, transition) +} + +@KauUtils fun View.setRippleBackground(@ColorInt foregroundColor: Int, @ColorInt backgroundColor: Int) { + background = createSimpleRippleDrawable(foregroundColor, backgroundColor) +} + +@KauUtils val View.parentViewGroup: ViewGroup + 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) + } +} + +/** + * Generates a recycler view with match parent and a linearlayoutmanager, since it's so commonly used + */ +fun Context.fullLinearRecycler(rvAdapter: RecyclerView.Adapter<*>? = null, configs: RecyclerView.() -> Unit = {}): RecyclerView { + return RecyclerView(this).apply { + layoutManager = LinearLayoutManager(this@fullLinearRecycler) + layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.MATCH_PARENT) + if (rvAdapter != null) adapter = rvAdapter + configs() + } +}
\ No newline at end of file diff --git a/core/src/main/kotlin/ca/allanwang/kau/views/BoundedCardView.kt b/core/src/main/kotlin/ca/allanwang/kau/views/BoundedCardView.kt new file mode 100644 index 0000000..0cb65d0 --- /dev/null +++ b/core/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/core/src/main/kotlin/ca/allanwang/kau/views/CutoutView.kt b/core/src/main/kotlin/ca/allanwang/kau/views/CutoutView.kt new file mode 100644 index 0000000..023bdb4 --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/views/CutoutView.kt @@ -0,0 +1,183 @@ +/* + * Copyright 2015 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ca.allanwang.kau.views + +import android.content.Context +import android.graphics.* +import android.graphics.drawable.Drawable +import android.text.TextPaint +import android.util.AttributeSet +import android.util.DisplayMetrics +import android.util.TypedValue +import android.view.View +import ca.allanwang.kau.R +import ca.allanwang.kau.utils.dimenPixelSize +import ca.allanwang.kau.utils.getFont +import ca.allanwang.kau.utils.parentVisibleHeight +import ca.allanwang.kau.utils.toBitmap + +/** + * A view which punches out some text from an opaque color block, allowing you to see through it. + */ +class CutoutView @JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 +) : View(context, attrs, defStyleAttr) { + + companion object { + const val PHI = 1.6182f + const val TYPE_TEXT = 100 + const val TYPE_DRAWABLE = 101 + } + + private val paint: TextPaint = TextPaint(Paint.ANTI_ALIAS_FLAG) + private var bitmapScaling: Float = 1f + private var cutout: Bitmap? = null + var foregroundColor = Color.MAGENTA + var text: String? = "Text" + set(value) { + field = value + if (value != null) cutoutType = TYPE_TEXT + else if (drawable != null) cutoutType = TYPE_DRAWABLE + } + var cutoutType: Int = TYPE_TEXT + private var textSize: Float = 0f + private var cutoutY: Float = 0f + private var cutoutX: Float = 0f + var drawable: Drawable? = null + set(value) { + field = value + if (value != null) cutoutType = TYPE_DRAWABLE + else if (text != null) cutoutType = TYPE_TEXT + } + private var heightPercentage: Float = 0f + private var minHeight: Float = 0f + private val maxTextSize: Float + + init { + if (attrs != null) { + val a = context.obtainStyledAttributes(attrs, R.styleable.CutoutView, 0, 0) + if (a.hasValue(R.styleable.CutoutView_font)) + paint.typeface = context.getFont(a.getString(R.styleable.CutoutView_font)) + foregroundColor = a.getColor(R.styleable.CutoutView_foregroundColor, foregroundColor) + text = a.getString(R.styleable.CutoutView_android_text) ?: text + minHeight = a.getDimension(R.styleable.CutoutView_android_minHeight, minHeight) + heightPercentage = a.getFloat(R.styleable.CutoutView_heightPercentageToScreen, heightPercentage) + a.recycle() + } + maxTextSize = context.dimenPixelSize(R.dimen.kau_display_4_text_size).toFloat() + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + calculatePosition() + createBitmap() + } + + private fun calculatePosition() { + when (cutoutType) { + TYPE_TEXT -> calculateTextPosition() + TYPE_DRAWABLE -> calculateImagePosition() + } + } + + private fun calculateTextPosition() { + val targetWidth = width / PHI + textSize = getSingleLineTextSize(text!!, paint, targetWidth, 0f, maxTextSize, + 0.5f, resources.displayMetrics) + paint.textSize = textSize + + // measuring text is fun :] see: https://chris.banes.me/2014/03/27/measuring-text/ + cutoutX = (width - paint.measureText(text)) / 2 + val textBounds = Rect() + paint.getTextBounds(text, 0, text!!.length, textBounds) + val textHeight = textBounds.height().toFloat() + cutoutY = (height + textHeight) / 2 + } + + private fun calculateImagePosition() { + if (drawable!!.intrinsicHeight <= 0 || drawable!!.intrinsicWidth <= 0) throw IllegalArgumentException("Drawable's intrinsic size cannot be less than 0") + val targetWidth = width / PHI + val targetHeight = height / PHI + bitmapScaling = Math.min(targetHeight / drawable!!.intrinsicHeight, targetWidth / drawable!!.intrinsicWidth) + cutoutX = (width - drawable!!.intrinsicWidth * bitmapScaling) / 2 + cutoutY = (height - drawable!!.intrinsicHeight * bitmapScaling) / 2 + } + + /** + * If height percent is specified, ensure it is met + */ + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val minHeight = Math.max(minHeight, heightPercentage * parentVisibleHeight) + val trueHeightMeasureSpec = if (minHeight > 0) + MeasureSpec.makeMeasureSpec(Math.max(minHeight.toInt(), measuredHeight), MeasureSpec.EXACTLY) + else heightMeasureSpec + super.onMeasure(widthMeasureSpec, trueHeightMeasureSpec) + } + + /** + * Recursive binary search to find the best size for the text. + + * Adapted from https://github.com/grantland/android-autofittextview + */ + fun getSingleLineTextSize(text: String, + paint: TextPaint, + targetWidth: Float, + low: Float, + high: Float, + precision: Float, + metrics: DisplayMetrics): Float { + val mid = (low + high) / 2.0f + + paint.textSize = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_PX, mid, metrics) + val maxLineWidth = paint.measureText(text) + + return if (high - low < precision) low + else if (maxLineWidth > targetWidth) getSingleLineTextSize(text, paint, targetWidth, low, mid, precision, metrics) + else if (maxLineWidth < targetWidth) getSingleLineTextSize(text, paint, targetWidth, mid, high, precision, metrics) + else mid + } + + private fun createBitmap() { + if (!(cutout?.isRecycled ?: true)) + cutout?.recycle() + if (width == 0 || height == 0) return + cutout = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + cutout!!.setHasAlpha(true) + val cutoutCanvas = Canvas(cutout!!) + cutoutCanvas.drawColor(foregroundColor) + paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR) + + when (cutoutType) { + TYPE_TEXT -> { + // this is the magic – Clear mode punches out the bitmap + paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR) + cutoutCanvas.drawText(text, cutoutX, cutoutY, paint) + } + TYPE_DRAWABLE -> { + cutoutCanvas.drawBitmap(drawable!!.toBitmap(bitmapScaling, Bitmap.Config.ALPHA_8), cutoutX, cutoutY, paint) + } + + } + } + + override fun onDraw(canvas: Canvas) { + canvas.drawBitmap(cutout!!, 0f, 0f, null) + } + + override fun hasOverlappingRendering(): Boolean = true + +} diff --git a/core/src/main/kotlin/ca/allanwang/kau/views/RippleCanvas.kt b/core/src/main/kotlin/ca/allanwang/kau/views/RippleCanvas.kt new file mode 100644 index 0000000..805fb21 --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/views/RippleCanvas.kt @@ -0,0 +1,140 @@ +package ca.allanwang.kau.views + +import android.animation.ArgbEvaluator +import android.animation.ValueAnimator +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.util.AttributeSet +import android.view.View +import ca.allanwang.kau.utils.adjustAlpha + +/** + * Created by Allan Wang on 2016-11-17. + * + * + * Canvas drawn ripples that keep the previous color + * Extends to view dimensions + * Supports multiple ripples from varying locations + */ +class RippleCanvas @JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 +) : View(context, attrs, defStyleAttr) { + private val paint: Paint = Paint() + private var baseColor = Color.TRANSPARENT + private val ripples: MutableList<Ripple> = mutableListOf() + + init { + paint.isAntiAlias = true + paint.style = Paint.Style.FILL + } + + /** + * Drawing the ripples involves having access to the next layer if it exists, + * and using its values to decide on the current color. + * If the next layer requests a fade, we will adjust the alpha of our current layer before drawing. + * Otherwise we will just draw the color as intended + */ + override fun onDraw(canvas: Canvas) { + val itr = ripples.listIterator() + if (!itr.hasNext()) return canvas.drawColor(baseColor) + var next = itr.next() + canvas.drawColor(colorToDraw(baseColor, next.fade, next.radius, next.maxRadius)) + var last = false + while (!last) { + val current = next + if (itr.hasNext()) next = itr.next() + else last = true + //We may fade any layer except for the last one + paint.color = colorToDraw(current.color, next.fade && !last, next.radius, next.maxRadius) + canvas.drawCircle(current.x, current.y, current.radius, paint) + if (current.radius == current.maxRadius) { + if (!last) { + itr.previous() + itr.remove() + itr.next() + } else { + itr.remove() + } + baseColor = current.color + } + } + } + + /** + * Given our current color and next layer's radius & max, + * we will decide on the alpha of our current layer + */ + internal fun colorToDraw(color: Int, fade: Boolean, current: Float, goal: Float): Int { + if (!fade || (current / goal <= FADE_PIVOT)) return color + val factor = (goal - current) / (goal - FADE_PIVOT * goal) + return color.adjustAlpha(factor) + } + + /** + * Creates a ripple effect from the given starting values + * [fade] will gradually transition previous ripples to a transparent color so the resulting background is what we want + * this is typically only necessary if the ripple color has transparency + */ + fun ripple(color: Int, startX: Float = 0f, startY: Float = 0f, duration: Long = 600L, fade: Boolean = Color.alpha(color) != 255) { + val w = width.toFloat() + val h = height.toFloat() + val x = when (startX) { + MIDDLE -> w / 2 + END -> w + else -> startX + } + val y = when (startY) { + MIDDLE -> h / 2 + END -> h + else -> startY + } + val maxRadius = Math.hypot(Math.max(x, w - x).toDouble(), Math.max(y, h - y).toDouble()).toFloat() + val ripple = Ripple(color, x, y, 0f, maxRadius, fade) + ripples.add(ripple) + val animator = ValueAnimator.ofFloat(0f, maxRadius) + animator.duration = duration + animator.addUpdateListener { animation -> + ripple.radius = animation.animatedValue as Float + invalidate() + } + animator.start() + } + + /** + * Sets a color directly; clears ripple queue if it exists + */ + fun set(color: Int) { + baseColor = color + ripples.clear() + invalidate() + } + + /** + * Sets a color directly but with a transition + */ + fun fade(color: Int, duration: Long = 300L) { + ripples.clear() + val animator = ValueAnimator.ofObject(ArgbEvaluator(), baseColor, color) + animator.duration = duration + animator.addUpdateListener { animation -> + baseColor = animation.animatedValue as Int + invalidate() + } + animator.start() + } + + internal class Ripple(val color: Int, + val x: Float, + val y: Float, + var radius: Float, + val maxRadius: Float, + val fade: Boolean) + + companion object { + const val MIDDLE = -1.0f + const val END = -2.0f + const val FADE_PIVOT = 0.5f + } +} diff --git a/core/src/main/kotlin/ca/allanwang/kau/views/SimpleRippleDrawable.kt b/core/src/main/kotlin/ca/allanwang/kau/views/SimpleRippleDrawable.kt new file mode 100644 index 0000000..df842f6 --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/views/SimpleRippleDrawable.kt @@ -0,0 +1,19 @@ +package ca.allanwang.kau.views + +import android.content.res.ColorStateList +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.RippleDrawable +import android.support.annotation.ColorInt +import ca.allanwang.kau.utils.adjustAlpha + +/** + * Created by Allan Wang on 2017-06-24. + * + * Tries to mimic a standard ripple, given the foreground and background colors + */ +fun createSimpleRippleDrawable(@ColorInt foregroundColor: Int, @ColorInt backgroundColor: Int): RippleDrawable { + val states = ColorStateList(arrayOf(intArrayOf()), intArrayOf(foregroundColor)) + val content = ColorDrawable(backgroundColor) + val mask = ColorDrawable(foregroundColor.adjustAlpha(0.16f)) + return RippleDrawable(states, content, mask) +}
\ No newline at end of file diff --git a/core/src/main/kotlin/ca/allanwang/kau/widgets/ElasticDragDismissFrameLayout.kt b/core/src/main/kotlin/ca/allanwang/kau/widgets/ElasticDragDismissFrameLayout.kt new file mode 100644 index 0000000..38c99c3 --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/widgets/ElasticDragDismissFrameLayout.kt @@ -0,0 +1,234 @@ +/* + * 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.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<ElasticDragDismissCallback> = mutableListOf() + + init { + if (attrs != null) { + val a = getContext().obtainStyledAttributes(attrs, R.styleable.ElasticDragDismissFrameLayout, 0, 0) + dragDismissDistance = a.getDimensionPixelSize(R.styleable.ElasticDragDismissFrameLayout_dragDismissDistance, Int.MAX_VALUE).toFloat() + dragDismissFraction = a.getFloat(R.styleable.ElasticDragDismissFrameLayout_dragDismissFraction, dragDismissFraction) + dragDismissScale = a.getFloat(R.styleable.ElasticDragDismissFrameLayout_dragDismissScale, dragDismissScale) + 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) + } + + override fun onStopNestedScroll(child: View) { + if (Math.abs(totalDrag) >= dragDismissDistance) { + dispatchDismissCallback() + } else { // settle back to natural position + animate() + .translationY(0f) + .scaleX(1f) + .scaleY(1f) + .setDuration(200L) + .setInterpolator(AnimHolder.fastOutSlowInInterpolator(context)) + .setListener(null) + .start() + totalDrag = 0f + draggingUp = false + draggingDown = draggingUp + dispatchDragCallback(0f, 0f, 0f, 0f) + } + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + if (dragDismissFraction > 0f) { + dragDismissDistance = h * dragDismissFraction + } + } + + fun addListener(listener: ElasticDragDismissCallback) { + callbacks.add(listener) + } + + fun removeListener(listener: ElasticDragDismissCallback) { + callbacks.remove(listener) + } + + private fun dragScale(scroll: Int) { + if (scroll == 0) return + + totalDrag += scroll.toFloat() + + // track the direction & set the pivot point for scaling + // don't double track i.e. if start dragging down and then reverse, keep tracking as + // dragging down until they reach the 'natural' position + if (scroll < 0 && !draggingUp && !draggingDown) { + draggingDown = true + if (shouldScale) pivotY = height.toFloat() + } else if (scroll > 0 && !draggingDown && !draggingUp) { + draggingUp = true + if (shouldScale) pivotY = 0f + } + // how far have we dragged relative to the distance to perform a dismiss + // (0–1 where 1 = dismiss distance). Decreasing logarithmically as we approach the limit + var dragFraction = Math.log10((1 + Math.abs(totalDrag) / dragDismissDistance).toDouble()).toFloat() + + // calculate the desired translation given the drag fraction + var dragTo = dragFraction * dragDismissDistance * dragElacticity + + if (draggingUp) { + // as we use the absolute magnitude when calculating the drag fraction, need to + // re-apply the drag direction + dragTo *= -1f + } + translationY = dragTo + + if (shouldScale) { + val scale = 1 - (1 - dragDismissScale) * dragFraction + scaleX = scale + scaleY = scale + } + + // if we've reversed direction and gone past the settle point then clear the flags to + // allow the list to get the scroll events & reset any transforms + if (draggingDown && totalDrag >= 0 || draggingUp && totalDrag <= 0) { + dragFraction = 0f + dragTo = dragFraction + totalDrag = dragTo + draggingUp = false + draggingDown = draggingUp + translationY = 0f + scaleX = 1f + scaleY = 1f + } + dispatchDragCallback(dragFraction, dragTo, + Math.min(1f, Math.abs(totalDrag) / dragDismissDistance), totalDrag) + } + + private fun dispatchDragCallback(elasticOffset: Float, elasticOffsetPixels: Float, + rawOffset: Float, rawOffsetPixels: Float) { + callbacks.forEach { + it.onDrag(elasticOffset, elasticOffsetPixels, + rawOffset, rawOffsetPixels) + } + } + + private fun dispatchDismissCallback() { + callbacks.forEach { it.onDragDismissed() } + } + + /** + * An [ElasticDragDismissCallback] which fades system chrome (i.e. status bar and + * navigation bar) whilst elastic drags are performed and + * [finishes][Activity.finishAfterTransition] the activity when drag dismissed. + */ + open class SystemChromeFader(private val activity: Activity) : ElasticDragDismissCallback() { + private val statusBarAlpha: Int = Color.alpha(activity.statusBarColor) + private val navBarAlpha: Int = Color.alpha(activity.navigationBarColor) + private val fadeNavBar: Boolean = activity.isNavBarOnBottom + + public override fun onDrag(elasticOffset: Float, elasticOffsetPixels: Float, + rawOffset: Float, rawOffsetPixels: Float) { + if (elasticOffsetPixels > 0) { + // dragging downward, fade the status bar in proportion + activity.statusBarColor = activity.statusBarColor.withAlpha(((1f - rawOffset) * statusBarAlpha).toInt()) + } else if (elasticOffsetPixels == 0f) { + // reset + activity.statusBarColor = activity.statusBarColor.withAlpha(statusBarAlpha) + activity.navigationBarColor = activity.navigationBarColor.withAlpha(navBarAlpha) + } else if (fadeNavBar) { + // dragging upward, fade the navigation bar in proportion + activity.navigationBarColor = activity.navigationBarColor.withAlpha(((1f - rawOffset) * navBarAlpha).toInt()) + } + } + + public override fun onDragDismissed() { + activity.finishAfterTransition() + } + } + +} diff --git a/core/src/main/kotlin/ca/allanwang/kau/widgets/InkPageIndicator.java b/core/src/main/kotlin/ca/allanwang/kau/widgets/InkPageIndicator.java new file mode 100644 index 0000000..78e915d --- /dev/null +++ b/core/src/main/kotlin/ca/allanwang/kau/widgets/InkPageIndicator.java @@ -0,0 +1,859 @@ +/* + * Copyright 2015 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ca.allanwang.kau.widgets; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.AnimatorSet; +import android.animation.ValueAnimator; +import android.content.Context; +import android.content.res.TypedArray; +import android.database.DataSetObserver; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.RectF; +import android.support.annotation.ColorInt; +import android.support.v4.view.ViewPager; +import android.util.AttributeSet; +import android.util.Log; +import android.view.View; +import android.view.animation.Interpolator; + +import java.util.Arrays; + +import ca.allanwang.kau.R; +import ca.allanwang.kau.utils.AnimHolder; +import ca.allanwang.kau.utils.ColorUtilsKt; + +/** + * An ink inspired widget for indicating pages in a {@link ViewPager}. + */ +public class InkPageIndicator extends View implements ViewPager.OnPageChangeListener, + View.OnAttachStateChangeListener { + + // defaults + private static final int DEFAULT_DOT_SIZE = 8; // dp + private static final int DEFAULT_GAP = 12; // dp + private static final int DEFAULT_ANIM_DURATION = 400; // ms + private static final int DEFAULT_UNSELECTED_COLOUR = 0x80ffffff; // 50% white + private static final int DEFAULT_SELECTED_COLOUR = 0xffffffff; // 100% white + + // constants + private static final float INVALID_FRACTION = -1f; + private static final float MINIMAL_REVEAL = 0.00001f; + + // configurable attributes + private int dotDiameter; + private int gap; + private long animDuration; + private int unselectedColour; + private int selectedColour; + + public void setColour(@ColorInt int color) { + selectedColour = color; + unselectedColour = ColorUtilsKt.adjustAlpha(color, 0.5f); + selectedPaint.setColor(selectedColour); + unselectedPaint.setColor(unselectedColour); + } + + // derived from attributes + private float dotRadius; + private float halfDotRadius; + private long animHalfDuration; + private float dotTopY; + private float dotCenterY; + private float dotBottomY; + + // ViewPager + private ViewPager viewPager; + + // state + private int pageCount; + private int currentPage; + private int previousPage; + private float selectedDotX; + private boolean selectedDotInPosition; + private float[] dotCenterX; + private float[] joiningFractions; + private float retreatingJoinX1; + private float retreatingJoinX2; + private float[] dotRevealFractions; + private boolean isAttachedToWindow; + private boolean pageChanging; + + // drawing + private final Paint unselectedPaint; + private final Paint selectedPaint; + private final Path combinedUnselectedPath; + private final Path unselectedDotPath; + private final Path unselectedDotLeftPath; + private final Path unselectedDotRightPath; + private final RectF rectF; + + // animation + private ValueAnimator moveAnimation; + private AnimatorSet joiningAnimationSet; + private PendingRetreatAnimator retreatAnimation; + private PendingRevealAnimator[] revealAnimations; + private final Interpolator interpolator; + + // working values for beziers + float endX1; + float endY1; + float endX2; + float endY2; + float controlX1; + float controlY1; + float controlX2; + float controlY2; + + public InkPageIndicator(Context context) { + this(context, null, 0); + } + + public InkPageIndicator(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public InkPageIndicator(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + + final int density = (int) context.getResources().getDisplayMetrics().density; + + // Load attributes + final TypedArray a = getContext().obtainStyledAttributes( + attrs, R.styleable.InkPageIndicator, defStyle, 0); + + dotDiameter = a.getDimensionPixelSize(R.styleable.InkPageIndicator_dotDiameter, + DEFAULT_DOT_SIZE * density); + dotRadius = dotDiameter / 2; + halfDotRadius = dotRadius / 2; + gap = a.getDimensionPixelSize(R.styleable.InkPageIndicator_dotGap, + DEFAULT_GAP * density); + animDuration = (long) a.getInteger(R.styleable.InkPageIndicator_animationDuration, + DEFAULT_ANIM_DURATION); + animHalfDuration = animDuration / 2; + unselectedColour = a.getColor(R.styleable.InkPageIndicator_pageIndicatorColor, + DEFAULT_UNSELECTED_COLOUR); + selectedColour = a.getColor(R.styleable.InkPageIndicator_currentPageIndicatorColor, + DEFAULT_SELECTED_COLOUR); + + a.recycle(); + + unselectedPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + unselectedPaint.setColor(unselectedColour); + selectedPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + selectedPaint.setColor(selectedColour); + interpolator = AnimHolder.INSTANCE.getFastOutSlowInInterpolator().invoke(context); + + // create paths & rect now – reuse & rewind later + combinedUnselectedPath = new Path(); + unselectedDotPath = new Path(); + unselectedDotLeftPath = new Path(); + unselectedDotRightPath = new Path(); + rectF = new RectF(); + + addOnAttachStateChangeListener(this); + } + + public void setViewPager(ViewPager viewPager) { + this.viewPager = viewPager; + viewPager.addOnPageChangeListener(this); + setPageCount(viewPager.getAdapter().getCount()); + viewPager.getAdapter().registerDataSetObserver(new DataSetObserver() { + @Override + public void onChanged() { + setPageCount(InkPageIndicator.this.viewPager.getAdapter().getCount()); + } + }); + setCurrentPageImmediate(); + } + + @Override + public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { + if (isAttachedToWindow) { + float fraction = positionOffset; + int currentPosition = pageChanging ? previousPage : currentPage; + int leftDotPosition = position; + // when swiping from #2 to #1 ViewPager reports position as 1 and a descending offset + // need to convert this into our left-dot-based 'coordinate space' + if (currentPosition != position) { + fraction = 1f - positionOffset; + + // if user scrolls completely to next page then the position param updates to that + // new page but we're not ready to switch our 'current' page yet so adjust for that + if (fraction == 1f) { + leftDotPosition = Math.min(currentPosition, position); + } + } + setJoiningFraction(leftDotPosition, fraction); + } + } + + @Override + public void onPageSelected(int position) { + if (isAttachedToWindow) { + // this is the main event we're interested in! + setSelectedPage(position); + } else { + // when not attached, don't animate the move, just store immediately + setCurrentPageImmediate(); + } + } + + @Override + public void onPageScrollStateChanged(int state) { + // nothing to do + } + + private void setPageCount(int pages) { + pageCount = pages; + resetState(); + requestLayout(); + } + + private void calculateDotPositions(int width, int height) { + int left = getPaddingLeft(); + int top = getPaddingTop(); + int right = width - getPaddingRight(); + int bottom = height - getPaddingBottom(); + + int requiredWidth = getRequiredWidth(); + float startLeft = left + ((right - left - requiredWidth) / 2) + dotRadius; + + dotCenterX = new float[pageCount]; + for (int i = 0; i < pageCount; i++) { + dotCenterX[i] = startLeft + i * (dotDiameter + gap); + } + // todo just top aligning for now… should make this smarter + dotTopY = top; + dotCenterY = top + dotRadius; + dotBottomY = top + dotDiameter; + + setCurrentPageImmediate(); + } + + private void setCurrentPageImmediate() { + if (viewPager != null) { + currentPage = viewPager.getCurrentItem(); + } else { + currentPage = 0; + } + if (dotCenterX != null) { + selectedDotX = dotCenterX[currentPage]; + } + } + + private void resetState() { + joiningFractions = new float[pageCount - 1]; + Arrays.fill(joiningFractions, 0f); + dotRevealFractions = new float[pageCount]; + Arrays.fill(dotRevealFractions, 0f); + retreatingJoinX1 = INVALID_FRACTION; + retreatingJoinX2 = INVALID_FRACTION; + selectedDotInPosition = true; + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + + int desiredHeight = getDesiredHeight(); + int height; + switch (MeasureSpec.getMode(heightMeasureSpec)) { + case MeasureSpec.EXACTLY: + height = MeasureSpec.getSize(heightMeasureSpec); + break; + case MeasureSpec.AT_MOST: + height = Math.min(desiredHeight, MeasureSpec.getSize(heightMeasureSpec)); + break; + case MeasureSpec.UNSPECIFIED: + default: + height = desiredHeight; + break; + } + + int desiredWidth = getDesiredWidth(); + int width; + switch (MeasureSpec.getMode(widthMeasureSpec)) { + case MeasureSpec.EXACTLY: + width = MeasureSpec.getSize(widthMeasureSpec); + break; + case MeasureSpec.AT_MOST: + width = Math.min(desiredWidth, MeasureSpec.getSize(widthMeasureSpec)); + break; + case MeasureSpec.UNSPECIFIED: + default: + width = desiredWidth; + break; + } + setMeasuredDimension(width, height); + calculateDotPositions(width, height); + } + + private int getDesiredHeight() { + return getPaddingTop() + dotDiameter + getPaddingBottom(); + } + + private int getRequiredWidth() { + return pageCount * dotDiameter + (pageCount - 1) * gap; + } + + private int getDesiredWidth() { + return getPaddingLeft() + getRequiredWidth() + getPaddingRight(); + } + + @Override + public void onViewAttachedToWindow(View view) { + isAttachedToWindow = true; + } + + @Override + public void onViewDetachedFromWindow(View view) { + isAttachedToWindow = false; + } + + @Override + protected void onDraw(Canvas canvas) { + if (viewPager == null || pageCount == 0) return; + drawUnselected(canvas); + drawSelected(canvas); + } + + private void drawUnselected(Canvas canvas) { + + combinedUnselectedPath.rewind(); + + // draw any settled, revealing or joining dots + for (int page = 0; page < pageCount; page++) { + int nextXIndex = page == pageCount - 1 ? page : page + 1; + combinedUnselectedPath.op(getUnselectedPath(page, + dotCenterX[page], + dotCenterX[nextXIndex], + page == pageCount - 1 ? INVALID_FRACTION : joiningFractions[page], + dotRevealFractions[page]), Path.Op.UNION); + } + // draw any retreating joins + if (retreatingJoinX1 != INVALID_FRACTION) { + combinedUnselectedPath.op(getRetreatingJoinPath(), Path.Op.UNION); + } + canvas.drawPath(combinedUnselectedPath, unselectedPaint); + } + + /** + * Unselected dots can be in 6 states: + * <p> + * #1 At rest + * #2 Joining neighbour, still separate + * #3 Joining neighbour, combined curved + * #4 Joining neighbour, combined straight + * #5 Join retreating + * #6 Dot re-showing / revealing + * <p> + * It can also be in a combination of these states e.g. joining one neighbour while + * retreating from another. We therefore create a Path so that we can examine each + * dot pair separately and later take the union for these cases. + * <p> + * This function returns a path for the given dot **and any action to it's right** e.g. joining + * or retreating from it's neighbour + * + * @param page + * @return + */ + private Path getUnselectedPath(int page, + float centerX, + float nextCenterX, + float joiningFraction, + float dotRevealFraction) { + + unselectedDotPath.rewind(); + + if ((joiningFraction == 0f || joiningFraction == INVALID_FRACTION) + && dotRevealFraction == 0f + && !(page == currentPage && selectedDotInPosition == true)) { + + // case #1 – At rest + unselectedDotPath.addCircle(dotCenterX[page], dotCenterY, dotRadius, Path.Direction.CW); + } + + if (joiningFraction > 0f && joiningFraction <= 0.5f + && retreatingJoinX1 == INVALID_FRACTION) { + + // case #2 – Joining neighbour, still separate + + // start with the left dot + unselectedDotLeftPath.rewind(); + + // start at the bottom center + unselectedDotLeftPath.moveTo(centerX, dotBottomY); + + // semi circle to the top center + rectF.set(centerX - dotRadius, dotTopY, centerX + dotRadius, dotBottomY); + unselectedDotLeftPath.arcTo(rectF, 90, 180, true); + + // cubic to the right middle + endX1 = centerX + dotRadius + (joiningFraction * gap); + endY1 = dotCenterY; + controlX1 = centerX + halfDotRadius; + controlY1 = dotTopY; + controlX2 = endX1; + controlY2 = endY1 - halfDotRadius; + unselectedDotLeftPath.cubicTo(controlX1, controlY1, + controlX2, controlY2, + endX1, endY1); + + // cubic back to the bottom center + endX2 = centerX; + endY2 = dotBottomY; + controlX1 = endX1; + controlY1 = endY1 + halfDotRadius; + controlX2 = centerX + halfDotRadius; + controlY2 = dotBottomY; + unselectedDotLeftPath.cubicTo(controlX1, controlY1, + controlX2, controlY2, + endX2, endY2); + + unselectedDotPath.op(unselectedDotLeftPath, Path.Op.UNION); + + // now do the next dot to the right + unselectedDotRightPath.rewind(); + + // start at the bottom center + unselectedDotRightPath.moveTo(nextCenterX, dotBottomY); + + // semi circle to the top center + rectF.set(nextCenterX - dotRadius, dotTopY, nextCenterX + dotRadius, dotBottomY); + unselectedDotRightPath.arcTo(rectF, 90, -180, true); + + // cubic to the left middle + endX1 = nextCenterX - dotRadius - (joiningFraction * gap); + endY1 = dotCenterY; + controlX1 = nextCenterX - halfDotRadius; + controlY1 = dotTopY; + controlX2 = endX1; + controlY2 = endY1 - halfDotRadius; + unselectedDotRightPath.cubicTo(controlX1, controlY1, + controlX2, controlY2, + endX1, endY1); + + // cubic back to the bottom center + endX2 = nextCenterX; + endY2 = dotBottomY; + controlX1 = endX1; + controlY1 = endY1 + halfDotRadius; + controlX2 = endX2 - halfDotRadius; + controlY2 = dotBottomY; + unselectedDotRightPath.cubicTo(controlX1, controlY1, + controlX2, controlY2, + endX2, endY2); + unselectedDotPath.op(unselectedDotRightPath, Path.Op.UNION); + } + + if (joiningFraction > 0.5f && joiningFraction < 1f + && retreatingJoinX1 == INVALID_FRACTION) { + + // case #3 – Joining neighbour, combined curved + + // adjust the fraction so that it goes from 0.3 -> 1 to produce a more realistic 'join' + float adjustedFraction = (joiningFraction - 0.2f) * 1.25f; + + // start in the bottom left + unselectedDotPath.moveTo(centerX, dotBottomY); + + // semi-circle to the top left + rectF.set(centerX - dotRadius, dotTopY, centerX + dotRadius, dotBottomY); + unselectedDotPath.arcTo(rectF, 90, 180, true); + + // bezier to the middle top of the join + endX1 = centerX + dotRadius + (gap / 2); + endY1 = dotCenterY - (adjustedFraction * dotRadius); + controlX1 = endX1 - (adjustedFraction * dotRadius); + controlY1 = dotTopY; + controlX2 = endX1 - ((1 - adjustedFraction) * dotRadius); + controlY2 = endY1; + unselectedDotPath.cubicTo(controlX1, controlY1, + controlX2, controlY2, + endX1, endY1); + + // bezier to the top right of the join + endX2 = nextCenterX; + endY2 = dotTopY; + controlX1 = endX1 + ((1 - adjustedFraction) * dotRadius); + controlY1 = endY1; + controlX2 = endX1 + (adjustedFraction * dotRadius); + controlY2 = dotTopY; + unselectedDotPath.cubicTo(controlX1, controlY1, + controlX2, controlY2, + endX2, endY2); + + // semi-circle to the bottom right + rectF.set(nextCenterX - dotRadius, dotTopY, nextCenterX + dotRadius, dotBottomY); + unselectedDotPath.arcTo(rectF, 270, 180, true); + + // bezier to the middle bottom of the join + // endX1 stays the same + endY1 = dotCenterY + (adjustedFraction * dotRadius); + controlX1 = endX1 + (adjustedFraction * dotRadius); + controlY1 = dotBottomY; + controlX2 = endX1 + ((1 - adjustedFraction) * dotRadius); + controlY2 = endY1; + unselectedDotPath.cubicTo(controlX1, controlY1, + controlX2, controlY2, + endX1, endY1); + + // bezier back to the start point in the bottom left + endX2 = centerX; + endY2 = dotBottomY; + controlX1 = endX1 - ((1 - adjustedFraction) * dotRadius); + controlY1 = endY1; + controlX2 = endX1 - (adjustedFraction * dotRadius); + controlY2 = endY2; + unselectedDotPath.cubicTo(controlX1, controlY1, + controlX2, controlY2, + endX2, endY2); + } + if (joiningFraction == 1 && retreatingJoinX1 == INVALID_FRACTION) { + + // case #4 Joining neighbour, combined straight technically we could use case 3 for this + // situation as well but assume that this is an optimization rather than faffing around + // with beziers just to draw a rounded rect + rectF.set(centerX - dotRadius, dotTopY, nextCenterX + dotRadius, dotBottomY); + unselectedDotPath.addRoundRect(rectF, dotRadius, dotRadius, Path.Direction.CW); + } + + // case #5 is handled by #getRetreatingJoinPath() + // this is done separately so that we can have a single retreating path spanning + // multiple dots and therefore animate it's movement smoothly + + if (dotRevealFraction > MINIMAL_REVEAL) { + + // case #6 – previously hidden dot revealing + unselectedDotPath.addCircle(centerX, dotCenterY, dotRevealFraction * dotRadius, + Path.Direction.CW); + } + + return unselectedDotPath; + } + + private Path getRetreatingJoinPath() { + unselectedDotPath.rewind(); + rectF.set(retreatingJoinX1, dotTopY, retreatingJoinX2, dotBottomY); + unselectedDotPath.addRoundRect(rectF, dotRadius, dotRadius, Path.Direction.CW); + return unselectedDotPath; + } + + private void drawSelected(Canvas canvas) { + canvas.drawCircle(selectedDotX, dotCenterY, dotRadius, selectedPaint); + } + + private void setSelectedPage(int now) { + if (now == currentPage) return; + + pageChanging = true; + previousPage = currentPage; + currentPage = now; + final int steps = Math.abs(now - previousPage); + + if (steps > 1) { + if (now > previousPage) { + for (int i = 0; i < steps; i++) { + setJoiningFraction(previousPage + i, 1f); + } + } else { + for (int i = -1; i > -steps; i--) { + setJoiningFraction(previousPage + i, 1f); + } + } + } + + // create the anim to move the selected dot – this animator will kick off + // retreat animations when it has moved 75% of the way. + // The retreat animation in turn will kick of reveal anims when the + // retreat has passed any dots to be revealed + moveAnimation = createMoveSelectedAnimator(dotCenterX[now], previousPage, now, steps); + moveAnimation.start(); + } + + private ValueAnimator createMoveSelectedAnimator( + final float moveTo, int was, int now, int steps) { + + // create the actual move animator + ValueAnimator moveSelected = ValueAnimator.ofFloat(selectedDotX, moveTo); + + // also set up a pending retreat anim – this starts when the move is 75% complete + retreatAnimation = new PendingRetreatAnimator(was, now, steps, + now > was ? + new RightwardStartPredicate(moveTo - ((moveTo - selectedDotX) * 0.25f)) : + new LeftwardStartPredicate(moveTo + ((selectedDotX - moveTo) * 0.25f))); + retreatAnimation.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + resetState(); + pageChanging = false; + } + }); + moveSelected.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { + @Override + public void onAnimationUpdate(ValueAnimator valueAnimator) { + // todo avoid autoboxing + selectedDotX = (Float) valueAnimator.getAnimatedValue(); + retreatAnimation.startIfNecessary(selectedDotX); + postInvalidateOnAnimation(); + } + }); + moveSelected.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(Animator animation) { + // set a flag so that we continue to draw the unselected dot in the target position + // until the selected dot has finished moving into place + selectedDotInPosition = false; + } + + @Override + public void onAnimationEnd(Animator animation) { + // set a flag when anim finishes so that we don't draw both selected & unselected + // page dots + selectedDotInPosition = true; + } + }); + // slightly delay the start to give the joins a chance to run + // unless dot isn't in position yet – then don't delay! + moveSelected.setStartDelay(selectedDotInPosition ? animDuration / 4l : 0l); + moveSelected.setDuration(animDuration * 3l / 4l); + moveSelected.setInterpolator(interpolator); + return moveSelected; + } + + private void setJoiningFraction(int leftDot, float fraction) { + if (leftDot < joiningFractions.length) { + + if (leftDot == 1) { + Log.d("PageIndicator", "dot 1 fraction:\t" + fraction); + } + + joiningFractions[leftDot] = fraction; + postInvalidateOnAnimation(); + } + } + + private void clearJoiningFractions() { + Arrays.fill(joiningFractions, 0f); + postInvalidateOnAnimation(); + } + + private void setDotRevealFraction(int dot, float fraction) { + dotRevealFractions[dot] = fraction; + postInvalidateOnAnimation(); + } + + private void cancelJoiningAnimations() { + if (joiningAnimationSet != null && joiningAnimationSet.isRunning()) { + joiningAnimationSet.cancel(); + } + } + + /** + * A {@link ValueAnimator} that starts once a given predicate returns true. + */ + public abstract class PendingStartAnimator extends ValueAnimator { + + protected boolean hasStarted; + protected StartPredicate predicate; + + public PendingStartAnimator(StartPredicate predicate) { + super(); + this.predicate = predicate; + hasStarted = false; + } + + public void startIfNecessary(float currentValue) { + if (!hasStarted && predicate.shouldStart(currentValue)) { + start(); + hasStarted = true; + } + } + } + + /** + * An Animator that shows and then shrinks a retreating join between the previous and newly + * selected pages. This also sets up some pending dot reveals – to be started when the retreat + * has passed the dot to be revealed. + */ + public class PendingRetreatAnimator extends PendingStartAnimator { + + public PendingRetreatAnimator(int was, int now, int steps, StartPredicate predicate) { + super(predicate); + setDuration(animHalfDuration); + setInterpolator(interpolator); + + // work out the start/end values of the retreating join from the direction we're + // travelling in. Also look at the current selected dot position, i.e. we're moving on + // before a prior anim has finished. + final float initialX1 = now > was ? Math.min(dotCenterX[was], selectedDotX) - dotRadius + : dotCenterX[now] - dotRadius; + final float finalX1 = now > was ? dotCenterX[now] - dotRadius + : dotCenterX[now] - dotRadius; + final float initialX2 = now > was ? dotCenterX[now] + dotRadius + : Math.max(dotCenterX[was], selectedDotX) + dotRadius; + final float finalX2 = now > was ? dotCenterX[now] + dotRadius + : dotCenterX[now] + dotRadius; + + revealAnimations = new PendingRevealAnimator[steps]; + // hold on to the indexes of the dots that will be hidden by the retreat so that + // we can initialize their revealFraction's i.e. make sure they're hidden while the + // reveal animation runs + final int[] dotsToHide = new int[steps]; + if (initialX1 != finalX1) { // rightward retreat + setFloatValues(initialX1, finalX1); + // create the reveal animations that will run when the retreat passes them + for (int i = 0; i < steps; i++) { + revealAnimations[i] = new PendingRevealAnimator(was + i, + new RightwardStartPredicate(dotCenterX[was + i])); + dotsToHide[i] = was + i; + } + addUpdateListener(new AnimatorUpdateListener() { + @Override + public void onAnimationUpdate(ValueAnimator valueAnimator) { + // todo avoid autoboxing + retreatingJoinX1 = (Float) valueAnimator.getAnimatedValue(); + postInvalidateOnAnimation(); + // start any reveal animations if we've passed them + for (PendingRevealAnimator pendingReveal : revealAnimations) { + pendingReveal.startIfNecessary(retreatingJoinX1); + } + } + }); + } else { // (initialX2 != finalX2) leftward retreat + setFloatValues(initialX2, finalX2); + // create the reveal animations that will run when the retreat passes them + for (int i = 0; i < steps; i++) { + revealAnimations[i] = new PendingRevealAnimator(was - i, + new LeftwardStartPredicate(dotCenterX[was - i])); + dotsToHide[i] = was - i; + } + addUpdateListener(new AnimatorUpdateListener() { + @Override + public void onAnimationUpdate(ValueAnimator valueAnimator) { + // todo avoid autoboxing + retreatingJoinX2 = (Float) valueAnimator.getAnimatedValue(); + postInvalidateOnAnimation(); + // start any reveal animations if we've passed them + for (PendingRevealAnimator pendingReveal : revealAnimations) { + pendingReveal.startIfNecessary(retreatingJoinX2); + } + } + }); + } + + addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(Animator animation) { + cancelJoiningAnimations(); + clearJoiningFractions(); + // we need to set this so that the dots are hidden until the reveal anim runs + for (int dot : dotsToHide) { + setDotRevealFraction(dot, MINIMAL_REVEAL); + } + retreatingJoinX1 = initialX1; + retreatingJoinX2 = initialX2; + postInvalidateOnAnimation(); + } + + @Override + public void onAnimationEnd(Animator animation) { + retreatingJoinX1 = INVALID_FRACTION; + retreatingJoinX2 = INVALID_FRACTION; + postInvalidateOnAnimation(); + } + }); + } + } + + /** + * An Animator that animates a given dot's revealFraction i.e. scales it up + */ + public class PendingRevealAnimator extends PendingStartAnimator { + + private int dot; + + public PendingRevealAnimator(int dot, StartPredicate predicate) { + super(predicate); + setFloatValues(MINIMAL_REVEAL, 1f); + this.dot = dot; + setDuration(animHalfDuration); + setInterpolator(interpolator); + addUpdateListener(new AnimatorUpdateListener() { + @Override + public void onAnimationUpdate(ValueAnimator valueAnimator) { + // todo avoid autoboxing + setDotRevealFraction(PendingRevealAnimator.this.dot, + (Float) valueAnimator.getAnimatedValue()); + } + }); + addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + setDotRevealFraction(PendingRevealAnimator.this.dot, 0f); + postInvalidateOnAnimation(); + } + }); + } + } + + /** + * A predicate used to start an animation when a test passes + */ + public abstract class StartPredicate { + + protected float thresholdValue; + + public StartPredicate(float thresholdValue) { + this.thresholdValue = thresholdValue; + } + + abstract boolean shouldStart(float currentValue); + + } + + /** + * A predicate used to start an animation when a given value is greater than a threshold + */ + public class RightwardStartPredicate extends StartPredicate { + + public RightwardStartPredicate(float thresholdValue) { + super(thresholdValue); + } + + boolean shouldStart(float currentValue) { + return currentValue > thresholdValue; + } + } + + /** + * A predicate used to start an animation then a given value is less than a threshold + */ + public class LeftwardStartPredicate extends StartPredicate { + + public LeftwardStartPredicate(float thresholdValue) { + super(thresholdValue); + } + + boolean shouldStart(float currentValue) { + return currentValue < thresholdValue; + } + } +} diff --git a/core/src/main/kotlin/ca/allanwang/kau/widgets/TextSlider.kt b/core/src/main/kotlin/ca/allanwang/kau/widgets/TextSlider.kt new file mode 100644 index 0000000..528dabc --- /dev/null +++ b/core/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<CharSequence?> = Stack() + + /** + * Holds a mapping of animation types to their respective animations + */ + val animationMap = mapOf( + ANIMATION_NONE to null, + ANIMATION_SLIDE_HORIZONTAL to AnimationBundle( + R.anim.kau_slide_in_right, R.anim.kau_slide_out_left, + R.anim.kau_slide_in_left, R.anim.kau_slide_out_right), + ANIMATION_SLIDE_VERTICAL to AnimationBundle( + R.anim.kau_slide_in_bottom, R.anim.kau_slide_out_top, + R.anim.kau_slide_in_top, R.anim.kau_slide_out_bottom + ) + ) + + /** + * Holds lazy instances of the animations + */ + inner class AnimationBundle(private val nextIn: Int, private val nextOut: Int, private val prevIn: Int, private val prevOut: Int) { + val NEXT_IN: Animation by lazy { AnimationUtils.loadAnimation(context, nextIn) } + val NEXT_OUT: Animation by lazy { AnimationUtils.loadAnimation(context, nextOut) } + val PREV_IN: Animation by lazy { AnimationUtils.loadAnimation(context, prevIn) } + val PREV_OUT: Animation by lazy { AnimationUtils.loadAnimation(context, prevOut) } + } + + companion object { + const val ANIMATION_NONE = 1000 + const val ANIMATION_SLIDE_HORIZONTAL = 1001 + const val ANIMATION_SLIDE_VERTICAL = 1002 + } + + var animationType: Int = ANIMATION_SLIDE_HORIZONTAL + + var textColor: Int = Color.WHITE + get() = field + set(value) { + field = value + (getChildAt(0) as TextView).setTextColor(value) + (getChildAt(1) as TextView).setTextColor(value) + } + val isRoot: Boolean + get() = titleStack.size <= 1 + + init { + if (attrs != null) { + val styledAttrs = context.obtainStyledAttributes(attrs, R.styleable.TextSlider) + animationType = styledAttrs.getInteger(R.styleable.TextSlider_animation_type, ANIMATION_SLIDE_HORIZONTAL) + styledAttrs.recycle() + } + } + + override fun setText(text: CharSequence?) { + if ((currentView as TextView).text == text) return + super.setText(text) + } + + override fun setCurrentText(text: CharSequence?) { + if (titleStack.isNotEmpty()) titleStack.pop() + titleStack.push(text) + super.setCurrentText(text) + } + + fun setNextText(text: CharSequence?) { + if (titleStack.isEmpty()) { + setCurrentText(text) + return + } + titleStack.push(text) + val anim = animationMap[animationType] + inAnimation = anim?.NEXT_IN + outAnimation = anim?.NEXT_OUT + setText(text) + } + + /** + * Sets the text as the previous title + * No further checks are done, so be sure to verify with [isRoot] + */ + @Throws(EmptyStackException::class) + fun setPrevText() { + titleStack.pop() + val anim = animationMap[animationType] + inAnimation = anim?.PREV_IN + outAnimation = anim?.PREV_OUT + val text = titleStack.peek() + setText(text) + } + + init { + setFactory { + TextView(context).apply { + //replica of toolbar title + gravity = Gravity.START + setSingleLine() + ellipsize = TextUtils.TruncateAt.END + TextViewCompat.setTextAppearance(this, R.style.TextAppearance_AppCompat_Title) + } + } + } +}
\ No newline at end of file |