diff --git a/commons/src/main/kotlin/com/simplemobiletools/commons/adapters/FilepickerFavoritesAdapter.kt b/commons/src/main/kotlin/com/simplemobiletools/commons/adapters/FilepickerFavoritesAdapter.kt index 2c43aed47..c05586a40 100644 --- a/commons/src/main/kotlin/com/simplemobiletools/commons/adapters/FilepickerFavoritesAdapter.kt +++ b/commons/src/main/kotlin/com/simplemobiletools/commons/adapters/FilepickerFavoritesAdapter.kt @@ -1,13 +1,13 @@ package com.simplemobiletools.commons.adapters import android.util.TypedValue -import android.view.Menu import android.view.View import android.view.ViewGroup import com.simplemobiletools.commons.R import com.simplemobiletools.commons.activities.BaseSimpleActivity import com.simplemobiletools.commons.extensions.getTextSize import com.simplemobiletools.commons.views.MyRecyclerView +import com.simplemobiletools.commons.views.bottomactionmenu.BottomActionMenuView import kotlinx.android.synthetic.main.filepicker_favorite.view.* class FilepickerFavoritesAdapter( @@ -35,7 +35,7 @@ class FilepickerFavoritesAdapter( override fun getItemCount() = paths.size - override fun prepareActionMode(menu: Menu) {} + override fun onBottomActionMenuCreated(view: BottomActionMenuView) {} override fun actionItemPressed(id: Int) {} @@ -47,8 +47,6 @@ class FilepickerFavoritesAdapter( override fun getItemSelectionKey(position: Int) = paths[position].hashCode() - override fun onActionModeCreated() {} - override fun onActionModeDestroyed() {} private fun setupView(view: View, path: String) { diff --git a/commons/src/main/kotlin/com/simplemobiletools/commons/adapters/FilepickerItemsAdapter.kt b/commons/src/main/kotlin/com/simplemobiletools/commons/adapters/FilepickerItemsAdapter.kt index 596feb3e6..9067064e3 100644 --- a/commons/src/main/kotlin/com/simplemobiletools/commons/adapters/FilepickerItemsAdapter.kt +++ b/commons/src/main/kotlin/com/simplemobiletools/commons/adapters/FilepickerItemsAdapter.kt @@ -3,7 +3,6 @@ package com.simplemobiletools.commons.adapters import android.content.pm.PackageManager import android.graphics.drawable.Drawable import android.util.TypedValue -import android.view.Menu import android.view.View import android.view.ViewGroup import com.bumptech.glide.Glide @@ -19,6 +18,7 @@ import com.simplemobiletools.commons.extensions.* import com.simplemobiletools.commons.helpers.getFilePlaceholderDrawables import com.simplemobiletools.commons.models.FileDirItem import com.simplemobiletools.commons.views.MyRecyclerView +import com.simplemobiletools.commons.views.bottomactionmenu.BottomActionMenuView import kotlinx.android.synthetic.main.item_filepicker_list.view.* import java.util.* @@ -55,7 +55,7 @@ class FilepickerItemsAdapter( override fun getItemCount() = fileDirItems.size - override fun prepareActionMode(menu: Menu) {} + override fun onBottomActionMenuCreated(view: BottomActionMenuView) {} override fun actionItemPressed(id: Int) {} @@ -67,8 +67,6 @@ class FilepickerItemsAdapter( override fun getItemSelectionKey(position: Int) = fileDirItems[position].path.hashCode() - override fun onActionModeCreated() {} - override fun onActionModeDestroyed() {} override fun onViewRecycled(holder: ViewHolder) { diff --git a/commons/src/main/kotlin/com/simplemobiletools/commons/adapters/ManageBlockedNumbersAdapter.kt b/commons/src/main/kotlin/com/simplemobiletools/commons/adapters/ManageBlockedNumbersAdapter.kt index ea5cf655a..d4f4ce503 100644 --- a/commons/src/main/kotlin/com/simplemobiletools/commons/adapters/ManageBlockedNumbersAdapter.kt +++ b/commons/src/main/kotlin/com/simplemobiletools/commons/adapters/ManageBlockedNumbersAdapter.kt @@ -1,6 +1,5 @@ package com.simplemobiletools.commons.adapters -import android.view.Menu import android.view.View import android.view.ViewGroup import com.simplemobiletools.commons.R @@ -10,6 +9,7 @@ import com.simplemobiletools.commons.extensions.deleteBlockedNumber import com.simplemobiletools.commons.interfaces.RefreshRecyclerViewListener import com.simplemobiletools.commons.models.BlockedNumber import com.simplemobiletools.commons.views.MyRecyclerView +import com.simplemobiletools.commons.views.bottomactionmenu.BottomActionMenuView import kotlinx.android.synthetic.main.item_manage_blocked_number.view.* import java.util.* @@ -23,10 +23,8 @@ class ManageBlockedNumbersAdapter( override fun getActionMenuId() = R.menu.cab_blocked_numbers - override fun prepareActionMode(menu: Menu) { - menu.apply { - findItem(R.id.cab_copy_number).isVisible = isOneItemSelected() - } + override fun onBottomActionMenuCreated(view: BottomActionMenuView) { + view.toggleItemVisibility(R.id.cab_copy_number, isOneItemSelected()) } override fun actionItemPressed(id: Int) { @@ -44,8 +42,6 @@ class ManageBlockedNumbersAdapter( override fun getItemKeyPosition(key: Int) = blockedNumbers.indexOfFirst { it.id.toInt() == key } - override fun onActionModeCreated() {} - override fun onActionModeDestroyed() {} override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = createViewHolder(R.layout.item_manage_blocked_number, parent) diff --git a/commons/src/main/kotlin/com/simplemobiletools/commons/adapters/MyRecyclerViewAdapter.kt b/commons/src/main/kotlin/com/simplemobiletools/commons/adapters/MyRecyclerViewAdapter.kt index fb03c8536..5536e708d 100644 --- a/commons/src/main/kotlin/com/simplemobiletools/commons/adapters/MyRecyclerViewAdapter.kt +++ b/commons/src/main/kotlin/com/simplemobiletools/commons/adapters/MyRecyclerViewAdapter.kt @@ -16,6 +16,10 @@ import com.simplemobiletools.commons.extensions.getAdjustedPrimaryColor import com.simplemobiletools.commons.extensions.getContrastColor import com.simplemobiletools.commons.interfaces.MyActionModeCallback import com.simplemobiletools.commons.views.MyRecyclerView +import com.simplemobiletools.commons.views.bottomactionmenu.BottomActionMenuView +import com.simplemobiletools.commons.views.bottomactionmenu.BottomActionMenuCallback +import com.simplemobiletools.commons.views.bottomactionmenu.BottomActionMenuItem +import com.simplemobiletools.commons.views.bottomactionmenu.BottomActionMenuPopup import java.util.* abstract class MyRecyclerViewAdapter(val activity: BaseSimpleActivity, val recyclerView: MyRecyclerView, val itemClick: (Any) -> Unit) : @@ -32,13 +36,15 @@ abstract class MyRecyclerViewAdapter(val activity: BaseSimpleActivity, val recyc protected var selectedKeys = LinkedHashSet() protected var positionOffset = 0 protected var actMode: ActionMode? = null + protected var contextCallback: BottomActionMenuCallback? = null + protected var contextPopup: BottomActionMenuPopup? = null private var actBarTextView: TextView? = null private var lastLongPressedItem = -1 abstract fun getActionMenuId(): Int - abstract fun prepareActionMode(menu: Menu) + abstract fun onBottomActionMenuCreated(view: BottomActionMenuView) abstract fun actionItemPressed(id: Int) @@ -50,24 +56,27 @@ abstract class MyRecyclerViewAdapter(val activity: BaseSimpleActivity, val recyc abstract fun getItemKeyPosition(key: Int): Int - abstract fun onActionModeCreated() - abstract fun onActionModeDestroyed() protected fun isOneItemSelected() = selectedKeys.size == 1 init { + contextCallback = object : BottomActionMenuCallback { + override fun onViewCreated(view: BottomActionMenuView) { + onBottomActionMenuCreated(view) + } + + override fun onItemClicked(item: BottomActionMenuItem) { + actionItemPressed(item.id) + } + } + actModeCallback = object : MyActionModeCallback() { override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { - actionItemPressed(item.itemId) - return true + return false } override fun onCreateActionMode(actionMode: ActionMode, menu: Menu?): Boolean { - if (getActionMenuId() == 0) { - return true - } - isSelectable = true actMode = actionMode actBarTextView = layoutInflater.inflate(R.layout.actionbar_title, null) as TextView @@ -80,18 +89,16 @@ abstract class MyRecyclerViewAdapter(val activity: BaseSimpleActivity, val recyc selectAll() } } - activity.menuInflater.inflate(getActionMenuId(), menu) - onActionModeCreated() return true } override fun onPrepareActionMode(actionMode: ActionMode, menu: Menu): Boolean { - prepareActionMode(menu) - return true + return false } override fun onDestroyActionMode(actionMode: ActionMode) { isSelectable = false + contextPopup?.dismiss() (selectedKeys.clone() as HashSet).forEach { val position = getItemKeyPosition(it) if (position != -1) { @@ -143,6 +150,7 @@ abstract class MyRecyclerViewAdapter(val activity: BaseSimpleActivity, val recyc if (oldTitle != newTitle) { actBarTextView?.text = newTitle actMode?.invalidate() + contextPopup?.invalidate() } } @@ -329,6 +337,7 @@ abstract class MyRecyclerViewAdapter(val activity: BaseSimpleActivity, val recyc val currentPosition = adapterPosition - positionOffset if (!actModeCallback.isSelectable) { activity.startSupportActionMode(actModeCallback) + contextPopup = BottomActionMenuPopup(activity, getActionMenuId()).also { it.show(contextCallback) } } toggleItemSelection(true, currentPosition, true) diff --git a/commons/src/main/kotlin/com/simplemobiletools/commons/adapters/MyRecyclerViewListAdapter.kt b/commons/src/main/kotlin/com/simplemobiletools/commons/adapters/MyRecyclerViewListAdapter.kt index 69efcd4f6..dec0bb2c8 100644 --- a/commons/src/main/kotlin/com/simplemobiletools/commons/adapters/MyRecyclerViewListAdapter.kt +++ b/commons/src/main/kotlin/com/simplemobiletools/commons/adapters/MyRecyclerViewListAdapter.kt @@ -18,6 +18,10 @@ import com.simplemobiletools.commons.extensions.getAdjustedPrimaryColor import com.simplemobiletools.commons.extensions.getContrastColor import com.simplemobiletools.commons.interfaces.MyActionModeCallback import com.simplemobiletools.commons.views.MyRecyclerView +import com.simplemobiletools.commons.views.bottomactionmenu.BottomActionMenuView +import com.simplemobiletools.commons.views.bottomactionmenu.BottomActionMenuCallback +import com.simplemobiletools.commons.views.bottomactionmenu.BottomActionMenuItem +import com.simplemobiletools.commons.views.bottomactionmenu.BottomActionMenuPopup import java.util.* abstract class MyRecyclerViewListAdapter( @@ -39,13 +43,15 @@ abstract class MyRecyclerViewListAdapter( protected var selectedKeys = LinkedHashSet() protected var positionOffset = 0 protected var actMode: ActionMode? = null + protected var contextCallback: BottomActionMenuCallback? = null + protected var contextPopup: BottomActionMenuPopup? = null private var actBarTextView: TextView? = null private var lastLongPressedItem = -1 abstract fun getActionMenuId(): Int - abstract fun prepareActionMode(menu: Menu) + abstract fun onBottomActionMenuCreated(view: BottomActionMenuView) abstract fun actionItemPressed(id: Int) @@ -57,24 +63,27 @@ abstract class MyRecyclerViewListAdapter( abstract fun getItemKeyPosition(key: Int): Int - abstract fun onActionModeCreated() - abstract fun onActionModeDestroyed() protected fun isOneItemSelected() = selectedKeys.size == 1 init { + contextCallback = object : BottomActionMenuCallback { + override fun onViewCreated(view: BottomActionMenuView) { + onBottomActionMenuCreated(view) + } + + override fun onItemClicked(item: BottomActionMenuItem) { + actionItemPressed(item.id) + } + } + actModeCallback = object : MyActionModeCallback() { override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { - actionItemPressed(item.itemId) - return true + return false } override fun onCreateActionMode(actionMode: ActionMode, menu: Menu?): Boolean { - if (getActionMenuId() == 0) { - return true - } - isSelectable = true actMode = actionMode actBarTextView = layoutInflater.inflate(R.layout.actionbar_title, null) as TextView @@ -87,18 +96,16 @@ abstract class MyRecyclerViewListAdapter( selectAll() } } - activity.menuInflater.inflate(getActionMenuId(), menu) - onActionModeCreated() return true } override fun onPrepareActionMode(actionMode: ActionMode, menu: Menu): Boolean { - prepareActionMode(menu) - return true + return false } override fun onDestroyActionMode(actionMode: ActionMode) { isSelectable = false + contextPopup?.dismiss() (selectedKeys.clone() as HashSet).forEach { val position = getItemKeyPosition(it) if (position != -1) { @@ -150,6 +157,7 @@ abstract class MyRecyclerViewListAdapter( if (oldTitle != newTitle) { actBarTextView?.text = newTitle actMode?.invalidate() + contextPopup?.invalidate() } } @@ -336,6 +344,7 @@ abstract class MyRecyclerViewListAdapter( val currentPosition = adapterPosition - positionOffset if (!actModeCallback.isSelectable) { activity.startSupportActionMode(actModeCallback) + contextPopup = BottomActionMenuPopup(activity, getActionMenuId()).also { it.show(contextCallback) } } toggleItemSelection(true, currentPosition, true) diff --git a/commons/src/main/kotlin/com/simplemobiletools/commons/views/bottomactionmenu/BottomActionMenuCallback.kt b/commons/src/main/kotlin/com/simplemobiletools/commons/views/bottomactionmenu/BottomActionMenuCallback.kt new file mode 100644 index 000000000..27830c0b7 --- /dev/null +++ b/commons/src/main/kotlin/com/simplemobiletools/commons/views/bottomactionmenu/BottomActionMenuCallback.kt @@ -0,0 +1,7 @@ +package com.simplemobiletools.commons.views.bottomactionmenu + +interface BottomActionMenuCallback { + fun onItemClicked(item: BottomActionMenuItem){} + fun onViewCreated(view: BottomActionMenuView){} + fun onViewDestroyed(){} +} diff --git a/commons/src/main/kotlin/com/simplemobiletools/commons/views/bottomactionmenu/BottomActionMenuItem.kt b/commons/src/main/kotlin/com/simplemobiletools/commons/views/bottomactionmenu/BottomActionMenuItem.kt new file mode 100644 index 000000000..49076407e --- /dev/null +++ b/commons/src/main/kotlin/com/simplemobiletools/commons/views/bottomactionmenu/BottomActionMenuItem.kt @@ -0,0 +1,13 @@ +package com.simplemobiletools.commons.views.bottomactionmenu + +import android.view.View +import androidx.annotation.DrawableRes +import androidx.annotation.IdRes + +data class BottomActionMenuItem( + @IdRes val id: Int, + val title: String, + @DrawableRes val icon: Int = View.NO_ID, + val showAsAction: Boolean, + val isVisible: Boolean = true, +) diff --git a/commons/src/main/kotlin/com/simplemobiletools/commons/views/bottomactionmenu/BottomActionMenuItemPopup.kt b/commons/src/main/kotlin/com/simplemobiletools/commons/views/bottomactionmenu/BottomActionMenuItemPopup.kt new file mode 100644 index 000000000..50a30c78c --- /dev/null +++ b/commons/src/main/kotlin/com/simplemobiletools/commons/views/bottomactionmenu/BottomActionMenuItemPopup.kt @@ -0,0 +1,253 @@ +package com.simplemobiletools.commons.views.bottomactionmenu + +import android.content.Context +import android.graphics.Rect +import android.view.* +import android.view.View.MeasureSpec +import android.widget.* +import androidx.core.content.ContextCompat +import androidx.core.widget.PopupWindowCompat +import com.simplemobiletools.commons.R +import com.simplemobiletools.commons.extensions.applyColorFilter +import com.simplemobiletools.commons.extensions.getAdjustedPrimaryColor +import com.simplemobiletools.commons.extensions.windowManager +import com.simplemobiletools.commons.helpers.isRPlus +import kotlinx.android.synthetic.main.item_action_mode_popup.view.* +import kotlin.math.ceil + +class BottomActionMenuItemPopup( + private val context: Context, + private val items: List, + private val onSelect: (BottomActionMenuItem) -> Unit +) { + + private val popup = PopupWindow(context, null, android.R.attr.popupMenuStyle) + private var anchorView: View? = null + private var dropDownWidth = ViewGroup.LayoutParams.WRAP_CONTENT + private var dropDownHeight = ViewGroup.LayoutParams.WRAP_CONTENT + private var dropDownVerticalOffset: Int = 0 + private var dropDownHorizontalOffset: Int = 0 + private val tempRect = Rect() + private val popupMinWidth: Int + private val popupPaddingBottom: Int + private val popupPaddingStart: Int + private val popupPaddingEnd: Int + private val popupPaddingTop: Int + private val dropDownGravity: Int = Gravity.TOP or Gravity.END + + private val popupListAdapter = object : ArrayAdapter(context, R.layout.item_action_mode_popup, items) { + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + var view = convertView + if (view == null) { + view = LayoutInflater.from(context).inflate(R.layout.item_action_mode_popup, parent, false) + } + val item = items[position] + view!!.cab_item.text = item.title + if (item.icon != View.NO_ID) { + val icon = ContextCompat.getDrawable(context, item.icon) + icon?.applyColorFilter(context.getAdjustedPrimaryColor()) + view.cab_item.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null) + } + view.setOnClickListener { + onSelect.invoke(item) + popup.dismiss() + } + return view + } + } + + init { + popup.isFocusable = true + popupMinWidth = context.resources.getDimensionPixelSize(R.dimen.cab_popup_menu_min_width) + popupPaddingStart = context.resources.getDimensionPixelSize(R.dimen.smaller_margin) + popupPaddingEnd = context.resources.getDimensionPixelSize(R.dimen.smaller_margin) + popupPaddingTop = context.resources.getDimensionPixelSize(R.dimen.smaller_margin) + popupPaddingBottom = context.resources.getDimensionPixelSize(R.dimen.smaller_margin) + } + + fun show(anchorView: View) { + this.anchorView = anchorView + buildDropDown() + PopupWindowCompat.setWindowLayoutType(popup, WindowManager.LayoutParams.TYPE_APPLICATION_SUB_PANEL) + popup.isOutsideTouchable = true + if (popup.isShowing) { + popup.update(anchorView, dropDownHorizontalOffset, dropDownVerticalOffset, dropDownWidth, if (dropDownHeight < 0) -1 else dropDownHeight) + } else { + popup.width = dropDownWidth + popup.height = dropDownHeight + PopupWindowCompat.showAsDropDown(popup, anchorView, dropDownHorizontalOffset, dropDownVerticalOffset, dropDownGravity) + } + } + + internal fun dismiss() { + popup.dismiss() + popup.contentView = null + } + + internal fun setOnDismissListener(listener: (() -> Unit)?) { + if (listener != null) { + popup.setOnDismissListener { listener.invoke() } + } else { + popup.setOnDismissListener(null) + } + } + + private fun buildDropDown() { + var otherHeights = 0 + val dropDownList = ListView(context).apply { + adapter = popupListAdapter + isFocusable = true + divider = null + isFocusableInTouchMode = true + clipToPadding = false + isVerticalScrollBarEnabled = false + isHorizontalScrollBarEnabled = false + clipToOutline = true + setPaddingRelative(popupPaddingStart, popupPaddingTop, popupPaddingEnd, popupPaddingBottom) + } + + val screenWidth = if (isRPlus()) { + context.windowManager.currentWindowMetrics.bounds.width() + } else { + context.windowManager.defaultDisplay.width + } + + val width = measureMenuSizeAndGetWidth((0.8 * screenWidth).toInt()) + updateContentWidth(width) + popup.contentView = dropDownList + + // getMaxAvailableHeight() subtracts the padding, so we put it back + // to get the available height for the whole window. + val padding: Int + val popupBackground = popup.background + if (popupBackground != null) { + popupBackground.getPadding(tempRect) + padding = tempRect.top + tempRect.bottom + + // If we don't have an explicit vertical offset, determine one from + // the window background so that content will line up. + dropDownVerticalOffset -= tempRect.top + } else { + tempRect.setEmpty() + padding = 0 + } + + if ((dropDownGravity and Gravity.BOTTOM) == Gravity.BOTTOM) { + dropDownVerticalOffset += anchorView!!.height + } + + val maxHeight = popup.getMaxAvailableHeight(anchorView!!, dropDownVerticalOffset) + val listContent = measureHeightOfChildrenCompat(maxHeight - otherHeights) + if (listContent > 0) { + val listPadding = dropDownList.paddingTop + dropDownList.paddingBottom + otherHeights += padding + listPadding + } + + dropDownHeight = listContent + otherHeights + dropDownList.layoutParams = ViewGroup.LayoutParams(dropDownWidth, dropDownHeight) + } + + private fun updateContentWidth(width: Int) { + val popupBackground = popup.background + dropDownWidth = if (popupBackground != null) { + popupBackground.getPadding(tempRect) + tempRect.left + tempRect.right + width + } else { + width + } + } + + /** + * @see androidx.appcompat.widget.DropDownListView.measureHeightOfChildrenCompat + */ + private fun measureHeightOfChildrenCompat(maxHeight: Int): Int { + + val parent = FrameLayout(context) + val widthMeasureSpec = MeasureSpec.makeMeasureSpec(dropDownWidth, MeasureSpec.EXACTLY) + + // Include the padding of the list + var returnedHeight = 0 + + val count = popupListAdapter.count + + var child: View? = null + var viewType = 0 + for (i in 0 until count) { + val positionType = popupListAdapter.getItemViewType(i) + if (positionType != viewType) { + child = null + viewType = positionType + } + child = popupListAdapter.getView(i, child, parent) + + // Compute child height spec + val heightMeasureSpec: Int + var childLp: ViewGroup.LayoutParams? = child.layoutParams + + if (childLp == null) { + childLp = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) + child.layoutParams = childLp + } + + heightMeasureSpec = if (childLp.height > 0) { + MeasureSpec.makeMeasureSpec( + childLp.height, + MeasureSpec.EXACTLY + ) + } else { + MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED) + } + child.measure(widthMeasureSpec, heightMeasureSpec) + // Since this view was measured directly against the parent measure + // spec, we must measure it again before reuse. + child.forceLayout() + + val marginLayoutParams = childLp as? ViewGroup.MarginLayoutParams + val topMargin = marginLayoutParams?.topMargin ?: 0 + val bottomMargin = marginLayoutParams?.bottomMargin ?: 0 + val verticalMargin = topMargin + bottomMargin + + returnedHeight += child.measuredHeight + verticalMargin + + if (returnedHeight >= maxHeight) { + // We went over, figure out which height to return. If returnedHeight > + // maxHeight, then the i'th position did not fit completely. + return maxHeight + } + } + + // At this point, we went through the range of children, and they each + // completely fit, so return the returnedHeight + return returnedHeight + } + + + /** + * @see androidx.appcompat.view.menu.MenuPopup.measureIndividualMenuWidth + */ + private fun measureMenuSizeAndGetWidth(maxAllowedWidth: Int): Int { + val parent = FrameLayout(context) + var maxWidth = popupMinWidth + var itemView: View? = null + var itemType = 0 + + val widthMeasureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED) + val heightMeasureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED) + for (i in 0 until popupListAdapter.count) { + val positionType: Int = popupListAdapter.getItemViewType(i) + if (positionType != itemType) { + itemType = positionType + itemView = null + } + itemView = popupListAdapter.getView(i, itemView, parent) + itemView.measure(widthMeasureSpec, heightMeasureSpec) + val itemWidth = itemView.measuredWidth + if (itemWidth >= maxAllowedWidth) { + return maxAllowedWidth + } else if (itemWidth > maxWidth) { + maxWidth = itemWidth + } + } + return maxWidth + } +} diff --git a/commons/src/main/kotlin/com/simplemobiletools/commons/views/bottomactionmenu/BottomActionMenuParser.kt b/commons/src/main/kotlin/com/simplemobiletools/commons/views/bottomactionmenu/BottomActionMenuParser.kt new file mode 100644 index 000000000..2b4e92e49 --- /dev/null +++ b/commons/src/main/kotlin/com/simplemobiletools/commons/views/bottomactionmenu/BottomActionMenuParser.kt @@ -0,0 +1,79 @@ +package com.simplemobiletools.commons.views.bottomactionmenu + +import android.content.Context +import android.util.AttributeSet +import android.util.Xml +import android.view.MenuItem +import android.view.View +import com.simplemobiletools.commons.R +import org.xmlpull.v1.XmlPullParser + +internal class BottomActionMenuParser(private val context: Context) { + companion object { + private const val NO_TEXT = "" + private const val MENU_TAG = "menu" + private const val MENU_ITEM_TAG = "item" + } + + fun inflate(menuId: Int): List { + val parser = context.resources.getLayout(menuId) + parser.use { + val attrs = Xml.asAttributeSet(parser) + return readContextItems(parser, attrs) + } + } + + private fun readContextItems(parser: XmlPullParser, attrs: AttributeSet): List { + val items = mutableListOf() + var eventType = parser.eventType + var tagName: String + + // This loop will skip to the menu start tag + do { + if (eventType == XmlPullParser.START_TAG) { + tagName = parser.name + + if (tagName == MENU_TAG) { + // Go to next tag + eventType = parser.next() + break + } + throw RuntimeException("Expecting menu, got $tagName") + } + eventType = parser.next() + } while (eventType != XmlPullParser.END_DOCUMENT) + + var reachedEndOfMenu = false + while (!reachedEndOfMenu) { + tagName = parser.name + + if (eventType == XmlPullParser.END_TAG) { + if (tagName == MENU_TAG) { + reachedEndOfMenu = true + } + } + + if (eventType == XmlPullParser.START_TAG) { + when (tagName) { + MENU_ITEM_TAG -> items.add(readBottomActionMenuItem(parser, attrs)) + } + } + + eventType = parser.next() + } + + return items + } + + private fun readBottomActionMenuItem(parser: XmlPullParser, attrs: AttributeSet): BottomActionMenuItem { + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.BottomActionMenuItem) + val id = typedArray.getResourceId(R.styleable.BottomActionMenuItem_android_id, View.NO_ID) + val text = typedArray.getString(R.styleable.BottomActionMenuItem_android_title) ?: NO_TEXT + val iconId = typedArray.getResourceId(R.styleable.BottomActionMenuItem_android_icon, View.NO_ID) + val showAsAction = typedArray.getInt(R.styleable.BottomActionMenuItem_showAsAction, -1) + val visible = typedArray.getBoolean(R.styleable.BottomActionMenuItem_android_visible, true) + typedArray.recycle() + parser.require(XmlPullParser.START_TAG, null, MENU_ITEM_TAG) + return BottomActionMenuItem(id, text, iconId, showAsAction == MenuItem.SHOW_AS_ACTION_ALWAYS || showAsAction == MenuItem.SHOW_AS_ACTION_IF_ROOM, visible) + } +} diff --git a/commons/src/main/kotlin/com/simplemobiletools/commons/views/bottomactionmenu/BottomActionMenuPopup.kt b/commons/src/main/kotlin/com/simplemobiletools/commons/views/bottomactionmenu/BottomActionMenuPopup.kt new file mode 100644 index 000000000..d7a6fb545 --- /dev/null +++ b/commons/src/main/kotlin/com/simplemobiletools/commons/views/bottomactionmenu/BottomActionMenuPopup.kt @@ -0,0 +1,74 @@ +package com.simplemobiletools.commons.views.bottomactionmenu + +import android.view.Gravity +import android.view.ViewGroup +import android.view.WindowManager +import android.view.accessibility.AccessibilityEvent +import android.widget.PopupWindow +import androidx.annotation.MenuRes +import androidx.core.widget.PopupWindowCompat +import com.google.android.material.floatingactionbutton.FloatingActionButton +import com.simplemobiletools.commons.activities.BaseSimpleActivity + + +class BottomActionMenuPopup(private val activity: BaseSimpleActivity, items: List) { + private val bottomActionMenuView = BottomActionMenuView(activity) + private val popup = PopupWindow(activity, null, android.R.attr.popupMenuStyle) + private var floatingActionButton: FloatingActionButton? = null + private var callback: BottomActionMenuCallback? = null + + + constructor(activity: BaseSimpleActivity, @MenuRes menuResId: Int) : this(activity, BottomActionMenuParser(activity).inflate(menuResId)) + + init { + popup.contentView = bottomActionMenuView + popup.width = ViewGroup.LayoutParams.MATCH_PARENT + popup.height = ViewGroup.LayoutParams.WRAP_CONTENT + popup.isOutsideTouchable = false + popup.setOnDismissListener { + callback?.onViewDestroyed() + floatingActionButton?.show() + } + PopupWindowCompat.setWindowLayoutType(popup, WindowManager.LayoutParams.TYPE_APPLICATION) + bottomActionMenuView.setup(items) + } + + fun show(callback: BottomActionMenuCallback?, hideFab: Boolean = true) { + this.callback = callback + callback?.onViewCreated(bottomActionMenuView) + if (hideFab) { + floatingActionButton?.hide() ?: findFABAndHide() + } + bottomActionMenuView.setCallback(callback) + bottomActionMenuView.sendAccessibilityEvent(AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) + popup.showAtLocation(bottomActionMenuView, Gravity.BOTTOM or Gravity.FILL_HORIZONTAL, 0, 0) + bottomActionMenuView.show() + } + + fun dismiss() { + popup.dismiss() + } + + private fun findFABAndHide() { + val parent = activity.findViewById(android.R.id.content) + findFab(parent) + floatingActionButton?.hide() + } + + private fun findFab(parent: ViewGroup) { + val count = parent.childCount + for (i in 0 until count) { + val child = parent.getChildAt(i) + if (child is FloatingActionButton) { + floatingActionButton = child + break + } else if (child is ViewGroup) { + findFab(child) + } + } + } + + fun invalidate() { + callback?.onViewCreated(bottomActionMenuView) + } +} diff --git a/commons/src/main/kotlin/com/simplemobiletools/commons/views/bottomactionmenu/BottomActionMenuView.kt b/commons/src/main/kotlin/com/simplemobiletools/commons/views/bottomactionmenu/BottomActionMenuView.kt new file mode 100644 index 000000000..cba0cc786 --- /dev/null +++ b/commons/src/main/kotlin/com/simplemobiletools/commons/views/bottomactionmenu/BottomActionMenuView.kt @@ -0,0 +1,228 @@ +package com.simplemobiletools.commons.views.bottomactionmenu + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.TimeInterpolator +import android.content.Context +import android.util.AttributeSet +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.View +import android.view.ViewPropertyAnimator +import android.widget.ImageView +import android.widget.LinearLayout +import androidx.annotation.DrawableRes +import androidx.annotation.IdRes +import androidx.annotation.MenuRes +import androidx.annotation.StringRes +import com.google.android.material.animation.AnimationUtils +import com.simplemobiletools.commons.R +import com.simplemobiletools.commons.extensions.* +import com.simplemobiletools.commons.helpers.isRPlus + + +class BottomActionMenuView : LinearLayout { + + companion object { + private const val ENTER_ANIMATION_DURATION = 225 + private const val EXIT_ANIMATION_DURATION = 175 + } + + constructor(context: Context) : super(context) + + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) + + constructor(context: Context, attrs: AttributeSet, defStyle: Int) : super(context, attrs, defStyle) + + private val inflater = LayoutInflater.from(context) + private val itemsLookup = LinkedHashMap() + private val items: List + get() = itemsLookup.values.toList().sortedWith(compareByDescending { it.showAsAction }.thenBy { it.icon != View.NO_ID }).filter { it.isVisible } + private var currentAnimator: ViewPropertyAnimator? = null + private var callback: BottomActionMenuCallback? = null + + init { + orientation = HORIZONTAL + elevation = 2f + setDefaultHeight() + } + + private fun setDefaultHeight() { + val typedValue = TypedValue() + val defaultHeight = TypedValue.complexToDimensionPixelSize(typedValue.data, resources.displayMetrics) + minimumHeight = defaultHeight + } + + fun setCallback(listener: BottomActionMenuCallback?) { + this.callback = listener + } + + fun hide() { + slideDownToGone() + } + + fun show() { + slideUpToVisible() + } + + private fun slideUpToVisible() { + currentAnimator?.also { + it.cancel() + clearAnimation() + } + animateChildTo(0, ENTER_ANIMATION_DURATION.toLong(), AnimationUtils.LINEAR_OUT_SLOW_IN_INTERPOLATOR, true) + } + + private fun slideDownToGone() { + currentAnimator?.also { + currentAnimator?.cancel() + clearAnimation() + } + animateChildTo( + height + (layoutParams as MarginLayoutParams).bottomMargin, + EXIT_ANIMATION_DURATION.toLong(), + AnimationUtils.FAST_OUT_LINEAR_IN_INTERPOLATOR + ) + } + + private fun animateChildTo(targetY: Int, duration: Long, interpolator: TimeInterpolator, visible: Boolean = false) { + currentAnimator = animate() + .translationY(targetY.toFloat()) + .setInterpolator(interpolator) + .setDuration(duration) + .setListener( + object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + currentAnimator = null + beVisibleIf(visible) + } + }) + } + + fun createFromMenu(@MenuRes menuResId: Int) { + if (menuResId != View.NO_ID) { + val menuParser = BottomActionMenuParser(context) + val items = menuParser.inflate(menuResId) + setup(items) + } + } + + fun setup(items: List) { + items.forEach { itemsLookup[it.id] = it } + init() + } + + fun add(item: BottomActionMenuItem) { + setItem(item) + } + + fun setItemShowAsAction(@IdRes itemId: Int, showAsAction: Boolean) { + val item = itemsLookup[itemId] + setItem(item?.copy(showAsAction = showAsAction)) + } + + fun changeItemIcon(@IdRes itemId: Int, @DrawableRes iconRes: Int) { + val item = itemsLookup[itemId] + setItem(item?.copy(icon = iconRes)) + } + + fun changeItemTitle(@IdRes itemId: Int, @StringRes stringRes: Int) { + changeItemTitle(itemId, context.getString(stringRes)) + } + + fun changeItemTitle(@IdRes itemId: Int, title: String) { + val item = itemsLookup[itemId] + setItem(item?.copy(title = title)) + } + + private fun setItem(item: BottomActionMenuItem?) { + item?.let { + itemsLookup[item.id] = item + init() + } + } + + fun toggleItemVisibility(@IdRes itemId: Int, show: Boolean) { + val item = itemsLookup[itemId] + setItem(item?.copy(isVisible = show)) + } + + private fun init() { + removeAllViews() + val maxItemsBeforeOverflow = computeMaxItemsBeforeOverflow() + val allItems = items + for (i in allItems.indices) { + if (i <= maxItemsBeforeOverflow) { + drawNormalItem(allItems[i]) + } else { + drawOverflowItem(allItems.slice(i until allItems.size)) + break + } + } + } + + private fun computeMaxItemsBeforeOverflow(): Int { + val itemsToShowAsAction = items.filter { it.showAsAction && it.icon != View.NO_ID } + val itemMinWidth = context.resources.getDimensionPixelSize(R.dimen.cab_item_min_width) + val totalActionWidth = (itemsToShowAsAction.size + 1) * itemMinWidth + val screenWidth = if (isRPlus()) { + context.windowManager.currentWindowMetrics.bounds.width() + } else { + context.windowManager.defaultDisplay.width + } + val result = if (screenWidth > totalActionWidth) { + itemsToShowAsAction.size + } else { + screenWidth / itemMinWidth + } + return result - 1 + } + + private fun drawNormalItem(item: BottomActionMenuItem) { + (inflater.inflate(R.layout.item_action_mode, this, false) as ImageView).apply { + setupItem(item) + setOnClickListener { + callback?.onItemClicked(item) + } + setOnLongClickListener { + context.toast(item.title) + true + } + addView(this) + } + } + + private fun drawOverflowItem(overFlowItems: List) { + (inflater.inflate(R.layout.item_action_mode, this, false) as ImageView).apply { + setImageResource(R.drawable.ic_three_dots_vector) + val contentDesc = context.getString(R.string.more_info) + contentDescription = contentDesc + applyColorFilter(context.getAdjustedPrimaryColor()) + val popup = getOverflowPopup(overFlowItems) + setOnClickListener { + popup.show(it) + } + setOnLongClickListener { + context.toast(contentDesc) + true + } + addView(this) + } + } + + private fun ImageView.setupItem(item: BottomActionMenuItem) { + id = item.id + contentDescription = item.title + if (item.icon != View.NO_ID) { + setImageResource(item.icon) + } + beVisibleIf(item.isVisible) + applyColorFilter(context.getAdjustedPrimaryColor()) + } + + private fun getOverflowPopup(overFlowItems: List): BottomActionMenuItemPopup { + return BottomActionMenuItemPopup(context, overFlowItems) { + callback?.onItemClicked(it) + } + } +} diff --git a/commons/src/main/res/layout/item_action_mode.xml b/commons/src/main/res/layout/item_action_mode.xml new file mode 100644 index 000000000..430d0eb43 --- /dev/null +++ b/commons/src/main/res/layout/item_action_mode.xml @@ -0,0 +1,12 @@ + + diff --git a/commons/src/main/res/layout/item_action_mode_popup.xml b/commons/src/main/res/layout/item_action_mode_popup.xml new file mode 100644 index 000000000..428c96317 --- /dev/null +++ b/commons/src/main/res/layout/item_action_mode_popup.xml @@ -0,0 +1,15 @@ + + diff --git a/commons/src/main/res/values/attrs.xml b/commons/src/main/res/values/attrs.xml new file mode 100644 index 000000000..f1b2becf8 --- /dev/null +++ b/commons/src/main/res/values/attrs.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/commons/src/main/res/values/dimens.xml b/commons/src/main/res/values/dimens.xml index 8d17008d0..2ea54f217 100644 --- a/commons/src/main/res/values/dimens.xml +++ b/commons/src/main/res/values/dimens.xml @@ -20,6 +20,8 @@ 70dp 48dp + 8dp + 40dp 72dp 56dp 76dp @@ -48,4 +50,8 @@ 18sp 20sp 22sp + + 336dp + 168dp + 85dp diff --git a/samples/src/main/kotlin/com/simplemobiletools/commons/samples/activities/StringsAdapter.kt b/samples/src/main/kotlin/com/simplemobiletools/commons/samples/activities/StringsAdapter.kt index 9be18312f..3b1d668da 100644 --- a/samples/src/main/kotlin/com/simplemobiletools/commons/samples/activities/StringsAdapter.kt +++ b/samples/src/main/kotlin/com/simplemobiletools/commons/samples/activities/StringsAdapter.kt @@ -1,6 +1,5 @@ package com.simplemobiletools.commons.samples.activities -import android.view.Menu import android.view.MotionEvent import android.view.View import android.view.ViewGroup @@ -15,6 +14,7 @@ import com.simplemobiletools.commons.interfaces.ItemTouchHelperContract import com.simplemobiletools.commons.interfaces.StartReorderDragListener import com.simplemobiletools.commons.samples.R import com.simplemobiletools.commons.views.MyRecyclerView +import com.simplemobiletools.commons.views.bottomactionmenu.BottomActionMenuView import kotlinx.android.synthetic.main.list_item.view.* import java.util.* @@ -41,7 +41,7 @@ class StringsAdapter( override fun getActionMenuId() = R.menu.cab_delete_only - override fun prepareActionMode(menu: Menu) {} + override fun onBottomActionMenuCreated(view: BottomActionMenuView) {} override fun actionItemPressed(id: Int) { if (selectedKeys.isEmpty()) { @@ -63,8 +63,6 @@ class StringsAdapter( override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = createViewHolder(R.layout.list_item, parent) - override fun onActionModeCreated() {} - override fun onActionModeDestroyed() { if (isChangingOrder) { notifyDataSetChanged()