# WandAndroidKotlin **Repository Path**: trydamer/wand-android-kotlin ## Basic Information - **Project Name**: WandAndroidKotlin - **Description**: WanAndroidKotlin版 - **Primary Language**: Unknown - **License**: Apache-2.0 - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 2 - **Forks**: 0 - **Created**: 2022-06-10 - **Last Updated**: 2023-11-14 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README ## WanAndroid—Kotlin ![version](https://img.shields.io/badge/version-1.0.0-brightgreen.svg) ![API](https://img.shields.io/badge/API-22-brightgreen.svg) ![LICENSE](https://badgen.net/badge/LICSENSE/Apache2.0/blue) ### 一、前言   本项目主要是为了学习Kotlin和Android而开发,无商业用途。代码和UI设计参考了[iceCola7/WanAndroid](https://github.com/iceCola7/WanAndroid)和[goweii/WanAndroid](https://github.com/goweii/WanAndroid)项目,所用API均来自[WanAndroid](https://www.wanandroid.com/)。我的[WanAndroid-Java版本点我跳转](https://gitee.com/trydamer/wan-android-java),由于Android趋势于Kotlin,Java版本的可能更新不那么频繁了。下文主要记录一些功能的实现原理以及遇到的问题和解决方案。 ### 二、功能实现原理 #### 2.1 注册登录背景 这里主要是图中弯进去的部分,通过自定义View来绘制的。弯进去的部分并不是一个圆弧,而是[贝塞尔曲线](https://blog.csdn.net/xiaozhangcsdn/article/details/98963937)。在onDraw方法中,找到控件的左右两个点的坐标,然后再根据参数找到控制点的坐标。用[Path](https://blog.csdn.net/w_t_y_y/article/details/59542548)来绘制一条二阶贝塞尔曲线,然后再首位相连即可。对应代码如下: ```kotlin // 起点 path.moveTo(0f, height.toFloat()) // 控制点和终止点 path.quadTo(x.toFloat(), (height - y).toFloat(), width.toFloat(), height.toFloat()) // path.close()方法是让最后一个点和起点连接起来,形成一个 // 封闭的区域,也可以调用lineTo(以直线连接,等价与close)或其他方法来 // 让其形成封闭区域 path.close() // 填充 paint.style = Paint.Style.FILL // 绘制 canvas.drawPath(path, paint) ``` #### 2.2 使用ViewBinding   使用ViewBinding可以提高我们的开发效率,它帮我们省掉了findViewById的过程,也不需要在Activity或Fragment等中定义许多View。使用方式如下: - 1. 在app下的build.gradle的android节点中声明使用ViewBinding ```gradle viewBinding { enabled = true } ``` - 2. 在Activity(或Fragment等)中创建ViewBinding ```kotlin private lateinit var binding: ActivityMainBinding override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityMainBinding.inflate(LayoutInflater.from(this)) setContentView(binding.root) binding.tvTitle.text = "ViewBinding" } ``` binding.tvTitle中的tvTitle就是layout中id为“tv_title”的控件。   为了更加方便,把ViewBinding加入到BaseActivity中,这样就不用写那么多重复的代码了,如下: ```kotlin abstract class BaseActivity( private val inflate: (layout: LayoutInflater) -> V ) : AppCompatActivity() { protected lateinit var binding: V override fun onCreate(saveInstanceState: Bundle?) { super.onCreate(saveInstanceState) binding = inflate(layoutInflater) setContentView(binding.root) initView() } protected abstract fun initView() } ``` 其他Activity使用时,代码如下: ```kotlin class OpenSourceActivity: BaseActivity(ActivityOpenSourceBinding::inflate) { private lateinit var openSourceAdapter: OpenSourceAdapter override fun initView() { openSourceAdapter = OpenSourceAdapter(this, getOpenSourceDatas()) binding.recyclerView.layoutManager = LinearLayoutManager(this) binding.recyclerView.isNestedScrollingEnabled = false binding.recyclerView.adapter = openSourceAdapter } } ``` Kotlin的一个好处之一就是可以像C语言一样把函数当作参数传递,如下: ```kotlin private val inflate: (layout: LayoutInflater) -> V ``` 上面这行代码的意思:inflate是一个方法,接收一个类型为LayoutInflater的参数,返回类型是V。   当然了,ViewBinding其实是通过生成中间代码为我们省去了findViewById等这些操作,本质上是没有变的。如下图所示,在OpenSourceActivity中的”ActivityOpenSourceBinding::inflate“就是传递了一个方法过去。 ![ActivityOpenSourceBinding.java](readme_img/viewbindingcode.png)   对于Fragment,也是如此。 #### 2.3 在Adapter中使用ViewBinding   在Adapter中使用ViewBinding要稍微复杂一些(仅仅复杂一些而已)。同样地,为了减少代码的重复性,定义一个基类Adapter,其他使用ViewBinding的Adapter继承该基类Adapter即可。 基类Adapter: ```kotlin /** * 使用ViewBinding的基类RecyclerView适配器 * * 数据类型 * ViewBiding * ViewHolder * * @param data 数据 * @param inflate ViewBinding的inflate * */ abstract class RecyclerViewBindingAdapter>( protected val context: Context, protected val data: List, private val inflate: (layout: LayoutInflater) -> VB ) : RecyclerView.Adapter() { /** * 重写创建ViewHolder方法,改变创建布局的方法 * * @param parent 父布局 * @param viewType 布局类型 * @return ViewHolder * */ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH { return inflate(LayoutInflater.from(parent.context)).run { onCreateViewHolder(this) } } override fun onBindViewHolder(holder: VH, position: Int) { onBindViewHolder(holder.binding, data[position], position) } override fun getItemCount(): Int { return data.size } abstract fun onCreateViewHolder(binding: VB): VH /** * 直接把binding和data传给实现者,使用方便 * * @param binding ViewBinding * @param item 数据 * @param position 第几项数据 * */ abstract fun onBindViewHolder(binding: VB, item: T, position: Int) } ``` 因为基类Adapter是继承RecyclerView.Adapter,因此还需要一个ViewHolder,如下: 基类ViewHolder: ```kotlin /** * RecyclerView使用ViewBinding的ViewHolder * */ open class ViewBindingViewHolder(val binding: V) : RecyclerView.ViewHolder(binding.root) ``` “我的”页面下的选项适配器: ```kotlin class MineSetAdapter( context: Context, data: List ) : RecyclerViewBindingAdapter>( context, data, MineSetItemBinding::inflate ) { private var onMineSetItemClickListener: OnMineSetItemClickListener? = null fun setOnMineSetItemClickListener(onMineSetItemClickListener: OnMineSetItemClickListener) { this.onMineSetItemClickListener = onMineSetItemClickListener } override fun onCreateViewHolder(binding: MineSetItemBinding): ViewBindingViewHolder { return ViewBindingViewHolder(binding) } override fun onBindViewHolder(binding: MineSetItemBinding, item: MineSetDTO, position: Int) { binding.imgIcon.setImageResource(item.icon) binding.tvTitle.text = item.title binding.tvContent.text = item.content binding.root.setOnClickListener { onMineSetItemClickListener?.run { onMineSetItemClick(item, position) } } } } ``` 布局文件xml: ```xml ``` 效果: 与不使用ViewBinding的Adapter相比,确实减少了许多工作量,这样我们便能够更集中于开发本身的事情了。 #### 2.4 流式布局——FlexboxLayout   先上效果图,不然可能说了半天都不知道流式布局是个什么玩意儿,如下图: 其实也就是这么个玩意儿,之前看到其他App的热搜/搜索历史时,还在想这是如何实现的,不过当时也只是想想罢了。关于流式布局,在[WanAndroid-Java](https://gitee.com/trydamer/wan-android)中已经详细说明了,这里提一下,就不再赘述。 #### 2.5 多选适配器   长按列表item进入多选状态,再次长按退出多选,效果图如下: [多选适配器](app/src/main/java/com/lcj/wanandroidkotlin/base/adapter/CheckAdapter.kt)首先加载自己的布局,然后再把子类的布局加载到该布局中即可。对于子类来说,和使用[RecyclerViewBindingAdapter](app/src/main/java/com/lcj/wanandroidkotlin/base/adapter/RecyclerViewBindingAdapter.kt)差不多。 布局如下: ```xml ``` ”选择框“默认隐藏,这样在显示的时候其实就和子布局一样,当进入选择模式后,把”选择框“显示出来即可达到上图的效果。创建ViewHolder的代码如下: ```kotlin /** * 重写{@link onCreateViewHolder}方法,屏蔽创建ViewHolder的方法。 * 先加载“多选”布局,然后再把子类的布局添加到“多选”布局中 * @param parent 父view * @param viewType 没有用到viewType * @return CheckViewHolder * */ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CheckViewHolder { return SelectRecyclerItemBinding.inflate( LayoutInflater.from(parent.context), parent, false ).run { CheckViewHolder(this) }.run { setSubViewBinding( inflate( LayoutInflater.from(binding.root.context), binding.root, true ) ) } } ``` 其中CheckViewHolder如下: ```kotlin open class CheckViewHolder( binding: SelectRecyclerItemBinding ) : ViewBindingViewHolder( binding ) { lateinit var subBinding: V fun setSubViewBinding(subBinding: V) = apply { this.subBinding = subBinding } } ``` 在绑定ViewHolder时,把subBinding传递给子类即可。 当然,该适配器并不适用于多列视图或者横向视图等,因为”选择框“总是出现在左边,而且该适配器还屏蔽了viewType,用于常规列表视图比较合适。 为什么不能把”选择框“放到右边?因为使用线性布局,如果把”选择框“放到右边,由于子布局是match_parent,所以”选择框“会被挤到屏幕之外了。当然也不是不可以把”选择框“放到右边,只是布局和逻辑要稍微复杂一点。把布局修改为: ```xml ``` 创建ViewHolder的代码改为: ```kotlin override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CheckViewHolder { return SelectRecyclerItemBinding.inflate( LayoutInflater.from(parent.context), parent, false ).run { CheckViewHolder(this) }.run { setSubViewBinding( inflate( LayoutInflater.from(binding.fltBody.context), binding.fltBody, true ) ) } } ``` 在onBindViewHolder中,需要做一些判断,关键代码如下: ```kotlin if (checkModel) { if (showOnRight) { holder.binding.imgCheckedLeft.visibility = View.GONE holder.binding.imgCheckedRight } else { holder.binding.imgCheckedRight.visibility = View.GONE holder.binding.imgCheckedLeft }.run { setImageResource( if (data[position].checked) { R.drawable.ic_selected_red } else { R.drawable.ic_un_select } ) visibility = View.VISIBLE } } else { holder.binding.imgCheckedRight.visibility = View.GONE holder.binding.imgCheckedLeft.visibility = View.GONE } ``` 在创建适配器时,指定”选择框“在左边还是右边即可。效果图如下图: 事实上只要能够实现一种,其他样式都只需要如法炮制即可。 #### 2.6 侧滑适配器   侧滑使用的框架是[com.daimajia.swipelayout](https://github.com/daimajia/AndroidSwipeLayout),[Java版](https://gitee.com/trydamer/wan-android)的侧滑框架使用的是[mcxtzhang/SwipeDelMenuLayout](https://github.com/mcxtzhang/SwipeDelMenuLayout),使用daimajia的要稍微复杂一些,但相对来说也比较好用。效果图如下: 添加依赖: ```gradle // 基础库 implementation 'com.daimajia.swipelayout:library:1.2.0@aar' // 动画库,可选 implementation 'com.daimajia.androidanimations:library:1.1.2@aar' ```   虽然框架提供了BaseSwipeAdapter,但该适配器是继承自ListView的,而我们使用的是RecyclerView,因此需要另一个BaseSwipeAdapter。先照着框架的抄一遍,然后再改改就行了,代码如下: ```kotlin abstract class SwipeAdapter>( context: Context, data: List, inflate: (layoutInflater: LayoutInflater, parent: ViewGroup, attachToParent: Boolean) -> VB ) : RecyclerViewBindingAdapter( context = context, data = data, inflate = inflate ) { private val swipeLayoutList = mutableListOf() /** * 关闭所有的SwipeLayout * @param filterLayout 屏蔽的SwipeLayout * */ fun closeAllSwipe(filterLayout: SwipeLayout?) { for (swipeLayout in swipeLayoutList) { if (filterLayout == swipeLayout) { continue } if (swipeLayout.openStatus != SwipeLayout.Status.Open) { continue } swipeLayout.close() } } override fun onBindViewHolder(holder: VH, position: Int) { super.onBindViewHolder(holder, position) val swipeLayout = getSwipeLayout(holder.binding, position) swipeLayout.addSwipeListener(object : SwipeLayout.SwipeListener { override fun onStartOpen(layout: SwipeLayout?) { closeAllSwipe(null) } override fun onOpen(layout: SwipeLayout?) { layout?.let { swipeLayoutList.add(it) } } override fun onStartClose(layout: SwipeLayout?) { } override fun onClose(layout: SwipeLayout?) { layout?.let { swipeLayoutList.remove(it) } } override fun onUpdate(layout: SwipeLayout?, leftOffset: Int, topOffset: Int) { } override fun onHandRelease(layout: SwipeLayout?, xvel: Float, yvel: Float) { } }) } abstract fun getSwipeLayout(binding: VB, position: Int): SwipeLayout } ``` 也许你使用的效果是这样的: 不要慌,虽然效果很怪异,但你也发现了这个SwipeLayout的原理。只是在使用的时候没有给item加上背景。因为当时我并没有在意demo的背景,百度也没有找着原因,只得慢慢摸索。我按照demo加了一个背景然后就可以了,在正常显示的item中加入背景即可。 #### 2.7 ItemDecoration   考虑一个问题,在侧滑列表中还有分割线,为什么侧滑显示的“按钮”没有紧挨着分割线。以往在列表中加入分割线,都是直接在布局中的最下面加一个View,如首页的文章适配器的布局就是这样做的: ```xml ``` 但是如果在侧滑的布局中也这样搞,那么侧滑时显示出来的“按钮”就和item不齐平。毕竟item的高度不一致,所以不能固定高度。   解决这个问题其实很简单,就是用[ItemDecoration](https://www.jianshu.com/p/8a51039d9e68),也许你知道它,但我是前两周才知道这个东西!!!   ItemDecoration有几个比较重要的方法: ```kotlin override fun getItemOffsets( outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State ) ``` 在这个方法中设置Item的上下左右边距,具体说明请看我下面盗的这张图: ![getItemOffsets方法图解](readme_img/item_decoration_offset.png) ```kotlin override fun onDraw( c: Canvas, parent: RecyclerView, state: RecyclerView.State ) ``` 这个方法和View的onDraw一样,画就完了。不同的是,在这个方法里面应该遍历parent的子view来画,即修饰每一个当前可见的item。 ```kotlin override fun onDrawOver( c: Canvas, parent: RecyclerView, state: RecyclerView.State ) ``` 这个方法也是绘制的,和onDraw唯一的区别就是调用顺序,简单来说: - onDraw 绘制的东西如果与item重叠,则重叠区域被item覆盖; - onDrawOver 绘制的东西如果与item重叠,则重叠区域被onDrawOver覆盖。   回到正题,我们在onDraw中绘制分割线即可: ```kotlin class SwipeItemDecoration(context: Context): RecyclerView.ItemDecoration() { private val paint: Paint = Paint() private val MARGIN_TOP = DeviceUtil.dp2px(10) private val MARGIN_BOTTOM = DeviceUtil.dp2px(10) private val LINE_START_X = DeviceUtil.dp2px(50.0F) private val LINE_END_X = DeviceUtil.dp2px(50.0F) private val LINE_BASE_Y = DeviceUtil.dp2px(10.0F) init { paint.strokeWidth = DeviceUtil.dp2px(1.0F) paint.color = if (ThemeUtils.isDarkMode(context)) { ContextCompat.getColor(context, R.color.night_split_line) } else { ContextCompat.getColor(context, R.color.light_split_line) } } override fun getItemOffsets( outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State ) { super.getItemOffsets(outRect, view, parent, state) outRect.set(0, MARGIN_TOP, 0, MARGIN_BOTTOM) } override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) { super.onDraw(c, parent, state) if (parent.childCount > 0) { /** * 最后一条数据不绘制分割线 * */ val count = if (parent.getChildAdapterPosition(parent.getChildAt(parent.childCount - 1)) == parent.adapter?.itemCount!! - 1) { parent.childCount - 1 } else { parent.childCount } for (index in 0 until count) { val childView = parent.getChildAt(index) val y = childView.bottom + LINE_BASE_Y c.drawLine(LINE_START_X, y, childView.right - LINE_END_X, y, paint) } } } } ``` 在onDraw中,通过 ```kotlin parent.getChildAdapterPosition(parent.getChildAt(parent.childCount - 1)) ``` 来判断当前view是不是最后一条数据的view,从而决定要不要画分割线,最后在页面中调用: ```kotlin binding.ildBody.recyclerView.addItemDecoration(SwipeItemDecoration(this)) ``` **关于ItemDecoration更多用法,可以参考我的另一个项目,[LogisticsDemo](https://gitee.com/trydamer/logistics-demo)** ### 三、问题记录与解决方案 #### 3.1 传递Parcelable问题   在Kotlin中,页面之间传递Parcelable尤为简单,在build.gradle中添加: ```gradle /*序列化插件*/ apply plugin: 'kotlin-parcelize' ``` 后即可直接使用注解的方式序列化,如下: ```kotlin import android.os.Parcelable import kotlinx.parcelize.Parcelize @Parcelize data class TestBean( val name: String, val test: List, val id: Long, val other: List, val remark: String, val detail: UserInfoBean ): Parcelable ``` 这样即可使用,不必像Java那样还要实现Parcelable的方法。但是,据说这种方式如果是混合开发(Kotlin+Java)会出现问题。当然我本人是没有验证过的,只是头铁,就要通过手动实现Parcelable方法来传递。   当数据没有List时一切正常,但是如果数据中有List,则传递会失败(只是List会出错,其他数据依然正常)。百度上查了许久,仍然找不到解决方案。最终只能分开传递,非List数据正常传递,Parcelable不写入也不读取List数据,List数据再另传。如下: ```kotlin KnowledgeActivity.start(requireContext(), parent as KnowledgeDTO, parent.children as ArrayList, childPosition) ``` 然后再在接收页面分别读取两个数据,最终合到一起即可。虽然这种方式相对要复杂一点,但也能满足需求。 #### 3.2使用ViewBinding相关问题   使用ViewBinding提高了开发效率,如需使用,在build.gradle的android节点中加入: ```gradle viewBinding { enabled = true } ``` 即可。在Activity(或Fragment)中使用如下: 基类Activity: ```kotlin abstract class BaseActivity(private val inflate: (layout: LayoutInflater) -> V) : AppCompatActivity(), KeyBoardAction { protected lateinit var binding: V override fun onCreate(saveInstanceState: Bundle?) { super.onCreate(saveInstanceState) binding = inflate(layoutInflater) setContentView(binding.root) initView() } // 省略其他代码 } ``` 其他Activity: ```kotlin class WebActivity: BaseActivity(ActivityWebBinding::inflate) { override fun initView() { binding.webView.loadUrl(intent.getStringExtra(ActivityConstant.WEB_ACTIVITY_URL)) } } ``` 非常方便。但是ViewBinding出现问题时也不好排查,下面记录一下遇到的问题 ##### 3.2.1 Adapter中的问题 基类Adapter: ```kotlin /** * 使用ViewBinding的RecyclerView适配器基类 * * 数据类型 * ViewBiding * ViewHolder * * @param data 数据 * @param inflate ViewBinding的inflate * */ abstract class RecyclerViewBindingAdapter>( protected val context: Context, protected val data: List, private val inflate: (layout: LayoutInflater) -> VB ) : RecyclerView.Adapter() { /** * 重写创建ViewHolder方法,改变创建布局的方法 * * @param parent 父布局 * @param viewType 布局类型 * @return ViewHolder * */ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH { return inflate(LayoutInflater.from(parent.context)).run { onCreateViewHolder(this) } } override fun onBindViewHolder(holder: VH, position: Int) { onBindViewHolder(holder.binding, data[position], position) } override fun getItemCount(): Int { return data.size } abstract fun onCreateViewHolder(binding: VB): VH /** * 直接把binding和data传给实现者,使用方便 * * @param binding ViewBinding * @param item 数据 * @param position 第几项数据 * */ abstract fun onBindViewHolder(binding: VB, item: T, position: Int) } ``` 基类ViewHolder: ```kotlin /** * RecyclerView使用ViewBinding的ViewHolder * */ open class ViewBindingViewHolder(val binding: V) : RecyclerView.ViewHolder(binding.root) ``` “我的”页面下的选项适配器: ```kotlin class MineSetAdapter( context: Context, data: List ) : RecyclerViewBindingAdapter>(context, data, MineSetItemBinding::inflate) { private var onMineSetItemClickListener: OnMineSetItemClickListener? = null fun setOnMineSetItemClickListener(onMineSetItemClickListener: OnMineSetItemClickListener) { this.onMineSetItemClickListener = onMineSetItemClickListener } override fun onCreateViewHolder(binding: MineSetItemBinding): ViewBindingViewHolder { return ViewBindingViewHolder(binding) } override fun onBindViewHolder(binding: MineSetItemBinding, item: MineSetDTO, position: Int) { binding.imgIcon.setImageResource(item.icon) binding.tvTitle.text = item.title binding.tvContent.text = item.content binding.root.setOnClickListener { onMineSetItemClickListener?.run { onMineSetItemClick(item, position) } } } } ``` 布局文件xml: ```xml ``` 实际效果: 布局文件中把item的高度设置为50dp,然而却达不到理想的效果。 **解决方案**   这个问题在BannerAdapter也出现了,因为只传递了LayoutInflater,没有传递parent等参数,所以在适配器中会出现问题,把参数 ```kotlin private val inflate: (layout: LayoutInflater) -> VB ``` 改成 ```kotlin private val inflate: (layout: LayoutInflater, parent: ViewGroup, attachToParent: Boolean) -> VB ``` 即可 #### 3.3 使用SwipeLayout的问题 **问题描述**   我想在侧滑的基础上加一个多选,让列表既可以侧滑,又可以多选。我在多选的布局中加入SwipeLayout。导致无法进入多选模式,点击事件也无效。即在2.5节中的布局中加入SwipeLayout,而设置长按和点击事件的控件是最外层的LinearLayout。 **解决方案**   把SwipeLayout放到外面,监听SwipeLayout的子View即可,父布局如下: ```xml ```   因为侧滑显示的控件不尽相同,所以在创建ViewHolder时再把具体的布局放到SwipeLayout中即可。 ### 四、扩展 #### 4.1 ffmpeg   上文的动图都是通过ffmpeg生成的,这里不是为了炫技哈,只是实在不想安装那些S十三软件。这里作者是直接使用编译好的软件,[点我下载](https://github.com/BtbN/FFmpeg-Builds/releases)。因为是使用命令操作,顺便记录一下一些常用命令。 ##### 4.1.1 视频生成gif ```shell ffmpeg -ss 4 -t 21 -i "E:\\KuaiVideos\\2022-07-03 17-13-06.mp4" -vf "crop=iw/3:ih:iw/3:0" check_adapter.gif ``` 上述命令的各个参数解释: - -ss 4 表示从视频的第4秒开始。 - -t 21表示时长为21秒。 - -i "E:\\...."是视频路径。 - -vf "crop=..."表示对视频进行裁剪,格式为:宽度:高度:起始x坐标:起始y坐标。上述crop参数的意思是:宽度为视频宽度的1/3,高度为视频高度,起始x坐标为视频宽度1/3的位置,起始y坐标为0。 - 最后的”check_adapter.gif“则是输出的文件名。 还有许多参数,由于本次没有使用到,所以就不记录了,后面用到再更新。至于上述命令中,为什么是1/3,请看原视频的尺寸: ![原视频尺寸](readme_img/ffmpeg_video_to_gif_crop.png) 如果不对视频进行裁剪,那么生成的gif图片尺寸和原视频的一样,看起来不太方便。而我需要的部分大概在x轴1/3开始到2/3的位置。 生成gif时缩放操作: ```shell -vf "scale=iw/3:ih/3" ```