diff options
Diffstat (limited to 'searchview/src/main/kotlin/ca/allanwang')
-rw-r--r-- | searchview/src/main/kotlin/ca/allanwang/kau/searchview/SearchItem.kt | 79 | ||||
-rw-r--r-- | searchview/src/main/kotlin/ca/allanwang/kau/searchview/SearchView.kt | 411 |
2 files changed, 490 insertions, 0 deletions
diff --git a/searchview/src/main/kotlin/ca/allanwang/kau/searchview/SearchItem.kt b/searchview/src/main/kotlin/ca/allanwang/kau/searchview/SearchItem.kt new file mode 100644 index 0000000..75d9b27 --- /dev/null +++ b/searchview/src/main/kotlin/ca/allanwang/kau/searchview/SearchItem.kt @@ -0,0 +1,79 @@ +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.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.layout.kau_search_iitem, + { ViewHolder(it) }, + R.id.kau_item_search +) { + + 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.kau_search_icon) + val title: TextView by bindView(R.id.kau_search_title) + val desc: TextView by bindView(R.id.kau_search_desc) + val container: ConstraintLayout by bindView(R.id.kau_search_item_frame) + } +}
\ No newline at end of file diff --git a/searchview/src/main/kotlin/ca/allanwang/kau/searchview/SearchView.kt b/searchview/src/main/kotlin/ca/allanwang/kau/searchview/SearchView.kt new file mode 100644 index 0000000..2178806 --- /dev/null +++ b/searchview/src/main/kotlin/ca/allanwang/kau/searchview/SearchView.kt @@ -0,0 +1,411 @@ +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.animators.NoAnimator +import ca.allanwang.kau.kotlin.nonReadable +import ca.allanwang.kau.searchview.SearchView.Configs +import ca.allanwang.kau.ui.views.BoundedCardView +import ca.allanwang.kau.utils.* +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.kau_search_shadow) + private val card: BoundedCardView by bindView(R.id.kau_search_cardview) + private val iconNav: ImageView by bindView(R.id.kau_search_nav) + private val editText: AppCompatEditText by bindView(R.id.kau_search_edit_text) + val textEvents: Observable<String> + private val progress: ProgressBar by bindView(R.id.kau_search_progress) + val iconExtra: ImageView by bindView(R.id.kau_search_extra) + private val iconClear: ImageView by bindView(R.id.kau_search_clear) + private val divider: View by bindView(R.id.kau_search_divider) + private val recycler: RecyclerView by bindView(R.id.kau_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 +} + |