背景
项目所用技术:taro/taro-ui/React。
一个摸鱼的上午,我的实习伙伴(导师),给了一个下图所示的需求:
功能细节如下:
- 父选项控制子选项列表状态,选中展开子选项列表,并将所有子选项设为选中状态;
- 父选项取消选中收起子选项列表,并将所有子选项设为未选中状态;
- 子选项可以单独设置开关状态,全关自动将父选项设为关闭状态并收起;
- 要求展开时有动画效果。
我一想这不就是个手风琴嘛,UI框架一套就完了。然而不幸的,taro-ui中的手风琴并不支持类似插槽一样的自定义功能,它长这样:
主要的问题是手风琴的箭头只能改样式,没法改成需求中的Switch组件(但其它部分应该是可以复用的,或许可以通过HOC实现需求,不过写的时候没考虑这么多啦)。
好吧,看来只能自己写了...
分析
这里我们来分析下实现这个需求需要做什么。
首先考虑页面结构,相信大家一眼就能看出来,就是文章开头画出来那样,直接看代码吧:
<Container>
<SettingGroup>
<SettingHeader><SettingHeader>
<SettingBody>
<SettingItem></SettingItem>
...
</SettingBody>
<SettingGroup>
<SettingGroup>
<SettingHeader><SettingHeader>
<SettingBody>
<SettingItem></SettingItem>
...
</SettingBody>
<SettingGroup>
...
</Container>
这里Container是页面容器;SettingGroup为一个选项组,SettingBody包含父选项(SettingHeader)和子选项列表(若干个SettingItem),我们的展开收起动画就是通过SettingBody控制的。
接下来想想怎样实现父选项状态和子选项同步(全开,全关),以及子选项全不选将父选项状态改为未选中。
// 父选项状态
// parentSettings[0].status;
// 子选项列表
// parentSettings[0].children;
// 子选项状态
// parentSettings[0].children[0].status;
// parentIdx为父选项下标,childIdx为子选项下标,status为按钮状态
// 触发选项开关的onClick时,调用此方法,通过下标取到对应元素,并更改状态
const updateSetting = useCallback((parentIdx, childIdx, status) => {
const parent = parentSettings[parentIdx];
// 如果子选项下标为-1,表示选中了父选项,这时将子选项状态同步
if (childIdx === -1) {
parent.status = status;
parent.children.forEach(item => item.status = status);
} else {
// 否则更改子选项状态,同时判断子选项是否全部关闭
parent.children[childIdx].status = status;
// 如果全开,父选项开,如果全关,则父选项也关,实际还有部分选择状态,这里简化了
parent.status = parent.children.every(item => item.status) ? true : false;
}
});
最后是如何实现手风琴动画,这个才是本文的重点,楼主因为考虑不周全导致动画部分重写了三次..
从直观上来说,我们只需要控制SettingBody的高度(height),再配合transition就能实现动画了。没错,这确实是正确的解决方案,但实际上操作下来,还是有很多细节的。下面是楼主在实践(改bug)过程中用到的三种方案,希望能抛砖引玉。
方案一:max-height
等等?上面才说控制height,这里怎么变成max-height了?原因很简单,要使用height,我们得有个确认的数值,这个数值就是指SettingBody的高度,它是要通过计算得到的,这种方法会在后两个方案中提到。
这也就是说我们给SettingBody设个height: auto,虽然内容可以撑开,高度也有设置,但transition是无法生效的,因为它算不出auto -> 0和0 -> auto的过程。
而max-height就无所谓了,只要我们能保证内容不超过max-height,那么通过max-height控制动画就是可行的。但它的缺陷也很明显,内容一旦超过max-height,就会产生溢出。
于是可以得到下面的CSS代码:
.active {
max-height: 50vh;
}
.deactive {
max-height: 0;
}
方案二:高度固定直接算
假如我们的样式是这样的:
.setting-item {
height: 50px;
...
}
那么很容易得到这样的结论:SettingBody的高度,就是若干个SettingItem的高度乘积,在这里就是height = 50 * children.length(children为子选项列表)。
接下来我们很愉快的把这里算出来的height设置到SettingBody上就能实现需求。
但依然很不幸,taro会将px转为rpx,而实际高度不同机型表现也不同,于是出现了iphone6下一切正常,iphone12下就高度超出了。(楼主也试了Taro.pxTransform,但它的作用就像${height}rpx一样,完全没有任何计算,可能是本人理解有误,望各位指出)
那么总结一下,这种方案适用于CSS高度固定的情况,我们可以直接算出来Body部分的高度,但如果有响应式之类的设置,就可能存在偏差了。
方案三:获取DOM高度
这是楼主采用的最终解决方案,但相对而言比上述两种方法麻烦一点。
时间回到接到需求后,我首先想到的是用taro-ui实现,发现它的手风琴组件无法定制时就想到了看看源码是如何实现的。我发现taro-ui的手风琴组件确实也是采用获取DOM高度的方式实现的,利用createSelector拿到DOM元素,再取到高度,根据状态设置展开或者收缩,一套操作行云流水。那既然没法直接用组件,就用实现思路吧。
但却被事实打脸了。一开始我是用的是下面的方式获取DOM元素:
setTimeout(() => {
createSelector().selector('.setting-body')...
}, 0)
发现拿到的是空数组。为什么呢?因为小程序渲染DOM是异步的,但当时我并没有意识到这个问题,转而认为是不是要用useRef拿。但改成useRef后,我发现拿到的东西似乎是一个ReactNode,多次尝试无果便放弃了,所以才有上面两种方案...
回到正题,出bug了肯定还是得改的,这时我已经将希望放在获取DOM元素上了。在搜索引擎上一通查找,得到的解决方案就是给setTimeout加上延时,稍经修改,得到了下面的代码:
setTimeout(() => {
createSelector().selector('.setting-body')...
// 注意200ms的延迟
}, 200)
这回终于拿到DOM元素了,顺理成章我们就能拿到元素的height了,接下来控制height就能完成需求啦。
但这样就结束了吗?其实还没有...
因为我们需求中父按钮关闭时,子按钮列表是收起的,表现在CSS上就是SettingBody的height为0。那么这时拿到的height也同样是0,于是展开选项卡时无法设置正确的高度。
所以退一步,我们这里就不能直接对SettingBody进行收起操作了,因为它收起后,无法正确获得高度。又想实现收起动画,又没法改变SettingBody的高度,就只能在外面再加一个容器啦,修改后的页面结构如下:
<Container>
<SettingGroup>
<SettingHeader><SettingHeader>
<GroupWrapper style={{ overflow: hidden, height: 收起 ? 0 : SettingBody高度 }}>
<SettingBody style={{ height: SettingBody高度 }}>
<SettingItem></SettingItem>
...
</SettingBody>
</GroupWrapper>
<SettingGroup>
</Container>
这样改造后,我们的动画就交给GroupWrapper执行了,而它又设置了overflow: hidden,自然就可以把下面的选项组给隐藏掉又不需要修改选项组高度了。
最后
这是我在掘金的第一篇文章,实际上个人一直没有发文章的习惯,所以也可以算第一篇公开发布的文章了。写一篇文章真的比想象中要难很多,希望自己能坚持下来。
以上,感谢大家看到最后。