Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature : NeumorphBottomNavigation #73

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions neumorphism/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ android {
dependencies {
implementation Libs.Kotlin.stdlib
implementation Libs.AndroidX.appcompat
implementation Libs.AndroidX.constraintlayout
}

afterEvaluate {
Expand Down
163 changes: 163 additions & 0 deletions neumorphism/src/main/java/soup/neumorphism/NeumorphBottomNavigation.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
package soup.neumorphism

import android.content.Context
import android.os.Bundle
import android.os.Parcelable
import android.transition.TransitionManager
import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup
import androidx.annotation.MenuRes
import androidx.appcompat.widget.AppCompatImageButton
import androidx.constraintlayout.helper.widget.Flow
import androidx.constraintlayout.widget.ConstraintLayout
import soup.neumorphism.internal.util.applyWindowInsets
import soup.neumorphism.internal.util.getChildren
import soup.neumorphism.model.MenuParser
import soup.neumorphism.model.MenuStyle
import soup.neumorphism.model.SavedState

class NeumorphBottomNavigation @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : ConstraintLayout(context, attrs) {

private var listener: OnMenuItemSelectedListener? = null
private val menuStyle: MenuStyle

@MenuRes
private var menuResource = -1

init {
val ta = context.obtainStyledAttributes(attrs, R.styleable.NeumorphBottomNavigation)

val menuResource = ta.getResourceId(R.styleable.NeumorphBottomNavigation_neumorph_menuResource, -1)
val leftInset = ta.getBoolean(R.styleable.NeumorphBottomNavigation_neumorph_setLeftInset, false)
val rightInset = ta.getBoolean(R.styleable.NeumorphBottomNavigation_neumorph_setRightInset, false)
val topInset = ta.getBoolean(R.styleable.NeumorphBottomNavigation_neumorph_setTopInset, false)
val bottomInset = ta.getBoolean(R.styleable.NeumorphBottomNavigation_neumorph_setBottomInset, false)

menuStyle = MenuStyle(context, ta)

ta.recycle()

if (menuResource >= 0) {
setMenuResource(menuResource)
}

applyWindowInsets(leftInset, rightInset, topInset, bottomInset)
}

fun setMenuResource(@MenuRes menuResource: Int) {
this.menuResource = menuResource

val menu = MenuParser(context).parse(menuResource, menuStyle)
val menuItemClickListener: (View) -> Unit = { view -> setMenuItemSelected(view.id) }

removeAllViews()

menu.items.forEach {
NeumorphFloatingActionButton(context)
.apply {
bind(it)
setOnClickListener(menuItemClickListener)
}.also(::addView)
}

getHorizontalFlow().apply {
referencedIds = menu.items.map { it.id }.toIntArray()
}.also(::addView)
}

fun setMenuItemSelected(id: Int, isSelected: Boolean = true) {
setMenuItemSelected(id, isSelected, true)
}

fun setOnMenuItemSelectedListener(action: (Int) -> Unit) {
setOnMenuItemSelectedListener(object : OnMenuItemSelectedListener {
override fun onItemSelected(id: Int) {
action(id)
}
})
}

fun setOnMenuItemSelectedListener(listener: OnMenuItemSelectedListener) {
this.listener = listener
}

private fun setMenuItemSelected(id: Int, isSelected: Boolean, dispatchAction: Boolean) {
val selectedMenuItem = getSelectedMenuItem()
when {
isSelected && selectedMenuItem?.id != id -> {
selectedMenuItem?.isSelected = false
getItemById(id)?.let {
it.isSelected = true
if (dispatchAction) {
listener?.onItemSelected(id)
}
}
}
!isSelected -> {
getItemById(id)?.let {
it.isSelected = false
}
}
}
}

private fun getItemById(id: Int): AppCompatImageButton? {
return getChildren()
.filterIsInstance<AppCompatImageButton>()
.firstOrNull { it.id == id }
}

private fun getSelectedMenuItem(): View? {
return getChildren().firstOrNull { it.isSelected }
}

private fun getSelectedMenuItemId(): Int {
return getSelectedMenuItem()?.id ?: -1
}

private fun getHorizontalFlow() = Flow(context).apply {
setOrientation(Flow.HORIZONTAL)
setHorizontalStyle(Flow.CHAIN_SPREAD)
setHorizontalAlign(Flow.HORIZONTAL_ALIGN_START)
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
}

override fun onSaveInstanceState(): Parcelable? {
val superState = super.onSaveInstanceState()
return SavedState(superState, Bundle()).apply {
menuRes = menuResource
selectedMenuItemId = getSelectedMenuItemId()
}
}

override fun onRestoreInstanceState(state: Parcelable?) {
when (state) {
is SavedState -> {
super.onRestoreInstanceState(state.superState)

if (state.menuRes != -1) {
setMenuResource(state.menuRes)
}
if (state.selectedMenuItemId != -1) {
setMenuItemSelected(
state.selectedMenuItemId,
isSelected = true,
dispatchAction = false
)
}
}
else -> super.onRestoreInstanceState(state)
}
}

interface OnMenuItemSelectedListener {
fun onItemSelected(id: Int)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@ import android.content.res.ColorStateList
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.util.Log
import android.view.ViewGroup
import androidx.annotation.ColorInt
import androidx.appcompat.widget.AppCompatImageButton
import soup.neumorphism.internal.util.NeumorphResources
import soup.neumorphism.internal.util.setColorStateListAnimator
import soup.neumorphism.model.MenuItem

class NeumorphFloatingActionButton @JvmOverloads constructor(
context: Context,
Expand All @@ -18,6 +21,7 @@ class NeumorphFloatingActionButton @JvmOverloads constructor(

private var isInitialized: Boolean = false
private val shapeDrawable: NeumorphShapeDrawable
private var menuItem: MenuItem? = null

private var insetStart = 0
private var insetEnd = 0
Expand Down Expand Up @@ -84,6 +88,22 @@ class NeumorphFloatingActionButton @JvmOverloads constructor(
isInitialized = true
}

fun bind(item: MenuItem) {
menuItem = item
id = item.id

val iconSize = item.menuStyle.iconSize
layoutParams = ViewGroup.LayoutParams(iconSize, iconSize)
setImageResource(item.icon)
scaleType = ScaleType.CENTER_INSIDE

setColorStateListAnimator(
item.selectedIconColor,
item.unselectedIconColor,
item.tintMode
)
}

override fun setBackground(drawable: Drawable?) {
setBackgroundDrawable(drawable)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package soup.neumorphism.internal.util

import android.content.Context
import android.util.TypedValue
import androidx.annotation.AttrRes


internal fun Context.getValueFromAttr(
@AttrRes attr: Int
): Int {
val typedValue = TypedValue()
theme.resolveAttribute(attr, typedValue, true)
return typedValue.data
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package soup.neumorphism.internal.util

import android.animation.Animator
import android.animation.ArgbEvaluator
import android.animation.StateListAnimator
import android.animation.ValueAnimator
import android.graphics.PorterDuff
import android.widget.ImageView
import androidx.annotation.ColorInt

private const val ICON_STATE_ANIMATOR_DURATION: Long = 250

internal fun ImageView.colorAnimator(
@ColorInt from: Int,
@ColorInt to: Int,
mode: PorterDuff.Mode?,
durationInMillis: Long
): Animator {
return ValueAnimator.ofObject(ArgbEvaluator(), from, to).apply {
duration = durationInMillis
addUpdateListener { animator ->
val color = animator.animatedValue as Int
mode?.let { setColorFilter(color, mode) } ?: run { setColorFilter(color) }
}
}
}

internal fun ImageView.setColorStateListAnimator(
@ColorInt color: Int,
@ColorInt unselectedColor: Int,
mode: PorterDuff.Mode?
) {
val stateList = StateListAnimator().apply {
addState(
intArrayOf(android.R.attr.state_selected),
colorAnimator(unselectedColor, color, mode, ICON_STATE_ANIMATOR_DURATION)
)
addState(
intArrayOf(),
colorAnimator(color, unselectedColor, mode, ICON_STATE_ANIMATOR_DURATION)
)
}

stateListAnimator = stateList

// Refresh the drawable state to avoid the unselected animation on view creation
refreshDrawableState()
}
61 changes: 61 additions & 0 deletions neumorphism/src/main/java/soup/neumorphism/internal/util/Insets.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package soup.neumorphism.internal.util

import android.view.View
import android.view.WindowInsets

/**
* Add the window inset to current padding
*
* @param left true to add the systemWindowInsetLeft to the padding false otherwise
* @param top true to add the systemWindowInsetTop to the padding false otherwise
* @param right true to add the systemWindowInsetRight to the padding false otherwise
* @param bottom true to add the systemWindowInsetBottom to the padding false otherwise
*/
internal fun View.applyWindowInsets(left: Boolean, top: Boolean, right: Boolean, bottom: Boolean) {
doOnApplyWindowInset { view, windowInsets, initialPadding ->
val leftPadding = initialPadding.left +
(windowInsets.systemWindowInsetLeft.takeIf { left } ?: 0)
val topPadding = initialPadding.top +
(windowInsets.systemWindowInsetTop.takeIf { top } ?: 0)
val rightPadding = initialPadding.right +
(windowInsets.systemWindowInsetRight.takeIf { right } ?: 0)
val bottomPadding = initialPadding.bottom +
(windowInsets.systemWindowInsetBottom.takeIf { bottom } ?: 0)
view.setPadding(leftPadding, topPadding, rightPadding, bottomPadding)
}
}

private fun View.doOnApplyWindowInset(f: (View, WindowInsets, InitialPadding) -> Unit) {
val initialPadding = recordInitialPaddingForView(this)
setOnApplyWindowInsetsListener { v, insets ->
f(v, insets, initialPadding)
insets
}

if (isAttachedToWindow) {
requestApplyInsets()
} else {
addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View) {
v.removeOnAttachStateChangeListener(this)
v.requestApplyInsets()
}

override fun onViewDetachedFromWindow(v: View) = Unit
})
}
}

private class InitialPadding(
val left: Int,
val top: Int,
val right: Int,
val bottom: Int
)

private fun recordInitialPaddingForView(view: View) = InitialPadding(
view.paddingLeft,
view.paddingTop,
view.paddingRight,
view.paddingBottom
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package soup.neumorphism.internal.util

import android.view.View
import android.view.ViewGroup


internal fun ViewGroup.getChildren(): Sequence<View> = object : Sequence<View> {
override fun iterator() = [email protected]()
}

private operator fun ViewGroup.iterator(): Iterator<View> = object : MutableIterator<View> {
private var index = 0
override fun hasNext() = index < childCount
override fun next() = getChildAt(index++) ?: throw IndexOutOfBoundsException()
override fun remove() = removeViewAt(--index)
}
5 changes: 5 additions & 0 deletions neumorphism/src/main/java/soup/neumorphism/model/Menu.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package soup.neumorphism.model

data class Menu(
val items: List<MenuItem>
)
15 changes: 15 additions & 0 deletions neumorphism/src/main/java/soup/neumorphism/model/MenuItem.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package soup.neumorphism.model

import android.graphics.PorterDuff
import androidx.annotation.ColorInt
import androidx.annotation.DrawableRes

class MenuItem(
@ColorInt val selectedIconColor: Int,
@ColorInt val unselectedIconColor: Int,
@DrawableRes val icon: Int,
val id: Int,
val title: CharSequence,
val tintMode: PorterDuff.Mode?,
val menuStyle: MenuStyle
)
Loading