1. 前言
现需要做一个目录的页面,虽然可以使用另起一个Activity
来解决,但是却做不出那种类似抽屉导航栏的效果。而这个效果刚好在番茄小说这个APP
中存在,平时也在使用。
而百度了一下,发现大家所使用的都是Dialog
来包装一个Activity
。所以接下来看看在番茄小说中是如何实现的。
考虑了一下,直接自定义View
控件即可,且考虑将其封装为一个第三方控件。
2. NavigationView学习
这个感觉封装的很好,至少站在使用的角度上来看是这样。传入headerLayout
和menu
就可以加载出对应的界面布局。所以在考虑封装之前有必要了解和学习这个控件。比如常见的使用如下:
<com.google.android.material.navigation.NavigationView
android:id="@+id/nav_view"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="end"
android:fitsSystemWindows="true"
app:headerLayout="@layout/nav_header"
app:menu="@menu/nav_item" />
app:headerLayout="@layout/nav_header"
和app:menu="@menu/nav_item"
分别对应各自的xml
文件。
2.1 结构
public class NavigationView extends ScrimInsetsFrameLayout
public class ScrimInsetsFrameLayout extends FrameLayout
也就是说NavigationView
其实是一个帧布局。在构造函数中,首先添加一个容器MenuView
:
this.addView((View)this.presenter.getMenuView(this));
然后将上面app:menu
和app:headerLayout
两个属性都加载读取出来:
// NavigationView
if (a.hasValue(styleable.NavigationView_menu)) {
this.inflateMenu(a.getResourceId(styleable.NavigationView_menu, 0));
}
if (a.hasValue(styleable.NavigationView_headerLayout)) {
this.inflateHeaderView(a.getResourceId(styleable.NavigationView_headerLayout, 0));
}
对于menu
:
// NavigationMenu
public void inflateMenu(int resId) {
this.presenter.setUpdateSuspended(true);
this.getMenuInflater().inflate(resId, this.menu);
this.presenter.setUpdateSuspended(false);
this.presenter.updateMenuView(false);
}
// 继承实现关系
public class NavigationMenu extends MenuBuilder
public class MenuBuilder implements SupportMenu
public interface SupportMenu extends Menu
在NavigationMenu
中提供了生成MenuItem
的方法。对于HeaderLayout
的布局文件,实例化为:
// NavigationView
public View inflateHeaderView(@LayoutRes int res) {
return this.presenter.inflateHeaderView(res);
}
private final NavigationMenuPresenter presenter;
// NavigationMenuPresenter
public View inflateHeaderView(@LayoutRes int res) {
View view = this.layoutInflater.inflate(res, this.headerLayout, false);
this.addHeaderView(view);
return view;
}
public void addHeaderView(@NonNull View view) {
this.headerLayout.addView(view);
this.menuView.setPadding(0, 0, 0, this.menuView.getPaddingBottom());
}
注意到在实例化的时候并没有对返回的view
进行处理,所以将实例化出来的view
添加到了NavigationMenuPresenter
中。headerLayout
定义为一个LinearLayout
。
this.headerLayout = (LinearLayout)this.layoutInflater.inflate(layout.design_navigation_item_header, this.menuView, false);
父控件menuView
为一个自定义控件NavigationMenuView
,继承自RecycleView
,并且实现了MenuView
接口。
3. 思路
考虑到NavigationMenuView
可以方便的传入item.xml
文件,且不用处理点击效果。决定就使用这个控件来作为打开的小说目录,因为方便简单。那么只需要做的就是:
- 自定义一个
ViewGroup
继承自FrameLayout
,然后配置其layout
即可。 - 暴露出打开
NavigationMenuView
和关闭的方法,然后使用属性动画,添加一个简单的动画效果即可。 - 对
NavigationMenuView
的item
点击事件添加监听,并返回其每个menuItem
的id
。 - 处理
Gravity
为start
和end
的时候的进入和退出动画。
效果:
4. 代码:
class HorizontalPopupLayout : BaseHorizontalPopupLayout {
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(
context,
attrs,
defStyleAttr
)
private var mPopupViewWidth: Int = 0
private var mNavigationView: NavigationView? = null
private var mIsOpenViewFlag: Boolean = false
private var mContentInLeft = false
private var onClickListener: IHorizontalPopupLayoutItemClickListener? = null
/**
* 设置menuItem点击的监听接口
*/
fun setOnItemClickListener(l: IHorizontalPopupLayoutItemClickListener) {
this.onClickListener = l
}
@SuppressLint("RtlHardcoded", "UseCompatLoadingForColorStateLists")
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
super.onLayout(changed, left, top, right, bottom)
if (childCount != 2) {
throw Exception("在{
$TAG}布局文件中,中必须有两个直接子元素!")
}
for (index in 0..childCount) {
val child = getChildAt(index)
if (child != null) {
if (child is NavigationView) {
mPopupViewWidth = child.measuredWidth
try {
mNavigationView = getChildAt(index) as NavigationView
} catch (e: java.lang.Exception) {
throw Exception("在{
$TAG}布局文件中,中第二个直接子元素必须为:NavigationView!")
}
when ((mNavigationView!!.layoutParams as FrameLayout.LayoutParams).gravity) {
Gravity.START, Gravity.LEFT -> {
child.layout(-mPopupViewWidth, 0, 0, measuredHeight)
mContentInLeft = true
}
Gravity.END, Gravity.RIGHT -> {
child.layout(
measuredWidth,
0,
measuredWidth + mPopupViewWidth,
measuredHeight
)
mContentInLeft = false
}
else -> {
child.layout(-mPopupViewWidth, 0, 0, measuredHeight)
mContentInLeft = true
}
}
mNavigationView!!.setNavigationItemSelectedListener(listener)
} else {
child.layout(0, 0, measuredWidth, measuredHeight)
}
}
}
if (mNavigationView == null || !(mNavigationView is NavigationView)) {
throw Exception("在{
$TAG}布局文件中,第二个直接子元素必须是NavigationView!")
}
}
private val listener = object : NavigationView.OnNavigationItemSelectedListener {
override fun onNavigationItemSelected(menuItem: MenuItem): Boolean {
onClickListener?.onItemClick(menuItem.itemId)
closeView()
return true
}
}
override fun onInterceptTouchEvent(event: MotionEvent?): Boolean {
when (event?.action) {
MotionEvent.ACTION_DOWN -> {
return isCloseNavigationView(event.x)
}
}
return super.onInterceptTouchEvent(event)
}
private fun isCloseNavigationView(x: Float): Boolean {
mNavigationView?.let {
if ((x < (measuredWidth - it.measuredWidth) && mIsOpenViewFlag && !mContentInLeft)
|| ((x > it.measuredWidth && mIsOpenViewFlag && mContentInLeft))
) {
closeView()
return true
}
}
return false
}
override fun closeView() {
if (!mIsOpenViewFlag) return
mIsOpenViewFlag = false
if (mContentInLeft) {
animation(-mPopupViewWidth, CLOSE_ANIMATION_TIME, AccelerateDecelerateInterpolator())
} else {
animation(mPopupViewWidth, CLOSE_ANIMATION_TIME, AccelerateDecelerateInterpolator())
}
}
override fun openView() {
if (mIsOpenViewFlag) return
mIsOpenViewFlag = true
if (mContentInLeft) {
animation(mPopupViewWidth, OPEN_ANIMATION_TIME, LinearInterpolator())
} else {
animation(-mPopupViewWidth, OPEN_ANIMATION_TIME, LinearInterpolator())
}
}
@SuppressLint("ObjectAnimatorBinding", "Recycle")
private fun animation(distance: Int, time: Long, interpolator: Interpolator) {
mNavigationView?.apply {
val animator = ObjectAnimator.ofFloat(
mNavigationView,
"translationX",
1f * distance
)
animator.duration = time
animator.interpolator = interpolator
animator.start()
}
}
companion object {
private val TAG = HorizontalPopupLayout.javaClass.name
}
}
其使用也就是需要将其包起来,且确保NavigationView
在最后一个,其前一个元素也就是主布局文件内容。
<com.weizu.contentview.HorizontalPopupLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/horizontalPopupLayout"
tools:context=".MainActivity">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
>
...
</LinearLayout>
<!-- 弹出菜单内容 -->
<com.google.android.material.navigation.NavigationView
android:id="@+id/nav_view"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="start"
android:fitsSystemWindows="true"
app:itemTextColor="@color/text_color"
app:headerLayout="@layout/nav_header"
app:menu="@menu/nav_item" />
</com.weizu.contentview.HorizontalPopupLayout>