使用 flutter 绘制 复杂 tabs 导航

1,362 阅读5分钟

前言

最近在使用 flutter 编写 app 时遇到一个很令人头疼的设计稿,具体效果如下:

image

image

image

可以发现,图形是不规则的,同时这种不规则的图形在不同的情况下展示效果也不一样,如果使用图片解决,又会出现阴影不协调的问题,所以得用到裁剪属性。

ClipPath

在flutter中实现这种不规则的图形,需要用到 ClipPath 这个 widget,具体用法如下:

class HeaderLeftClipPath extends CustomClipper<Path> {
  @override
  Path getClip(Size size) {
    const itemWidth = 168.0;
    const bottomHeight = 0;
    var path = Path()
      ..moveTo(0, size.height)
      ..lineTo(0, 20)
      ..quadraticBezierTo(0, 0, 20, 0)
      ..lineTo(itemWidth - 40, 0)
      ..quadraticBezierTo(itemWidth - 20, 0, itemWidth - 20, 20)
      ..lineTo(itemWidth - 20, size.height - 20 - bottomHeight)
      ..quadraticBezierTo(itemWidth - 20, size.height - bottomHeight, itemWidth, size.height - bottomHeight)
      ..lineTo(size.width, size.height - bottomHeight)
      ..lineTo(size.width, size.height)
      ..close();

    return path;
  }

  @override
  bool shouldReclip(CustomClipper<Path> oldClipper) => false;
}

class Header extends StatelessWidget {
    @override
    build() {
        return _buildLeftHeader()
    }

    _buildLeftHeader() {
        return ClipPath(
            clipper: HeaderLeftClipPath(),
            child: Container(
                width: 168,
                height: 60,
                padding: EdgeInsets.only(right: 20),
                decoration: BoxDecoration(
                    color: Color(0xffff0000)
                ),
            ),
        );
    }
}

它的作用在于根据定义的路径进行裁剪后得到需要的图形,其中绘制路径时可以使用 flutter 提供的 api 进行特殊路径的绘制,例如贝塞尔曲线,经过上述裁剪,就能得到这样一个图形,也就是订单头部的左侧导航。

image

接着我们再绘制中间部位的导航形状。

class HeaderCenterClipPath extends CustomClipper<Path> {
  @override
  Path getClip(Size size) {
    var path = Path()
      ..moveTo(0, size.height)
      ..quadraticBezierTo(20, size.height, 20, size.height -  20)
      ..lineTo(20, 20)
      ..quadraticBezierTo(20, 0, 40, 0)
      ..lineTo(size.width - 40, 0)
      ..quadraticBezierTo(size.width - 20, 0, size.width - 20, 20)
      ..lineTo(size.width - 20, size.height - 20)
      ..quadraticBezierTo(size.width - 20, size.height, size.width, size.height)
      ..close();

    return path;
  }

  @override
  bool shouldReclip(CustomClipper<Path> oldClipper) => false;
}

class Header extends StatelessWidget {
    @override
    build() {
        return _buildCenterHeader()
    }

    _buildCenterHeader() {
        return ClipPath(
            clipper: HeaderCenterClipPath(),
            child: Container(
                width: 187,
                height: 60,
                padding: EdgeInsets.only(right: 20),
                decoration: BoxDecoration(
                    color: Color(0xffff0000)
                ),
            ),
        );
    }
}

得到形状如下:

image

最后再绘制右侧导航

class HeaderRightClipPath extends CustomClipper<Path> {
  @override
  Path getClip(Size size) {
    print(size);
    var path = Path()
      ..moveTo(0, size.height)
      ..quadraticBezierTo(20, size.height, 20, size.height - 20)
      ..lineTo(20, 20)
      ..quadraticBezierTo(20, 0, 40, 0)
      ..lineTo(size.width - 20, 0)
      ..quadraticBezierTo(size.width, 0, size.width, 20)
      ..lineTo(size.width, size.height)
      ..close();

    return path;
  }

  @override
  bool shouldReclip(CustomClipper<Path> oldClipper) => false;
}

class Header extends StatelessWidget {
    @override
    build() {
        return _buildRightHeader()
    }

    _buildRightHeader() {
        return ClipPath(
            clipper: HeaderRightClipPath(),
            child: Container(
                width: 187,
                height: 60,
                padding: EdgeInsets.only(right: 20),
                decoration: BoxDecoration(
                    color: Color(0xffff0000)
                ),
            ),
        );
    }
}

得到最终的图案

image

将他们使用 Stack 布局汇总在一起,得到效果如下。

image

有点丑,哈哈,主要的原因是缺少阴影,以及背景色与设计稿不符,所以现在我们给剪切后的图形添加阴影效果。需要注意的是,如果直接给 ClipPath 部件包裹 Container,并且添加阴影效果,是达不到设计稿那样的效果的,原因在于即使 Container 被裁剪,但实际的大小还是原来的大小,所以阴影部分也需要绘制来达到效果。

绘制曲线阴影

因为自身也是 flutter 的新手,对于曲线阴影这种效果也不知道如何实现,于是在 google 中搜索得到了解决方案,具体看这里

话不多说,直接 command cv。

使用此组件,对上面的代码进行改造,得到最终效果如下:

image

再把背景色切换为白色:

image

效果更加明显,为了使头部与底部融合为一体,需要在视觉上对用户进行欺骗,所以得把底部的阴影去掉。

class HeaderContainerPath extends CustomClipper<Rect> {
  @override
  Rect getClip(Size size) {
    // TODO: implement getClip
    return Rect.fromLTRB(-10, 0, size.width, size.height);
  }

  @override
  bool shouldReclip(CustomClipper<Rect> oldClipper) {
    // TODO: implement shouldReclip
    return false;
  }
}

_buildHeader() {
    return ClipRect(
      clipper: HeaderContainerPath(),
      child: Container(
        height: 60,
        child: Stack(
          children: <Widget>[
            Positioned(
              left: 0,
              child: _buildLeftHeader(),
            ),
            Positioned(
              left: 135 - 8.0,
              child: _buildCenterHeader(),
            ),
            Positioned(
              left: 283 - 9.0,
              child: _buildRightHeader(),
            )
          ],
        ),
      ),
    );
  }

因为头部的裁剪是一个矩形,所以我们这里需要用到 ClipRect 这个部件,同时 CustomClipper 范型需要指定为 Rect, 同时 getClip 返回一个 Rect 对象。因为需要保留最左侧和头部的阴影,所以裁剪时,需要向左和向上偏移 10px。

Rect.fromLTRB(-10, -10, size.width, size.height)

得到效果如下:

image

再给底部容器添加阴影,得到一个融合为一体的容器,如图:

image

效果貌似还不错,不过有一点细节没有完成,那就是激活的 tab 会有一个阴影效果覆盖其它的 tab,如图所示:

image

如果用常规的思维来实现,那么非常麻烦,这里我们换一个思维方式来实现这个效果,添加一个渐变容器来模拟阴影效果。代码如下所示:


_buildHeader() {
    return ClipRect(
        clipper: HeaderContainerPath(),
        child: Container(
            height: 60,
            child: Stack(
                children: <Widget>[
                    Positioned(
                        left: 0,
                        child: _buildLeftHeader(),
                    ),
                    Positioned(
                        left: 135 - 8.0,
                        child: _buildCenterHeader(),
                    ),
                    Positioned(
                        bottom: 0,
                        left: 0,
                        right: 0,
                        child: _buildShadow(), // 阴影放置在倒数第二的位置
                    ),
                    Positioned(
                        left: 283 - 9.0,
                        child: _buildRightHeader(),
                    )
                ],
            ),
        ),
    );
}

Widget _buildShadow() {
    return Container(
        height: 8,
        decoration: BoxDecoration(
        gradient: LinearGradient(colors: [Color.fromRGBO(255, 255,255, 0), Color.fromRGBO(0, 0, 0, 0.1)], begin: Alignment.topCenter, end: Alignment.bottomCenter)
        ),
    );
}


结果如图:

image

右边的阴影有点深,是因为叠加了两层阴影,这个之后再解决。阴影容器放在倒数第二的位置是因为 flutter 没有 css 中 zIndex 的概念,层级是以代码的顺序为准,在 stack 布局中,写在最后的代码层级是最高的,所以阴影放置在倒数第二的位置,覆盖其它的 tab, 同时保证当前激活的 tab 不会被覆盖。

点击进行切换

接下来进行点击切换的讲解,因为 flutter 不存在 zIndex,所以在点击的时候,我们需要改变 widget 在代码中的位置来提升激活 tab 的层级,代码如下:

_buildHeader() {
    List<Function> tabOrder = [_buildLeftHeader, _buildCenterHeader, _buildRightHeader];
    Function activeOrder = tabOrder.removeAt(activeIndex); // 先移除并取出激活的 tab
    tabOrder = tabOrder.reversed.toList();
    tabOrder.add(_buildShadow); // 把阴影放到倒数第二的位置
    tabOrder.add(activeOrder); // 最终将激活的 tab 放入最后

    return ClipRect(
      clipper: HeaderContainerPath(),
      child: Container(
        height: 60,
        child: Stack(
          children: tabOrder.map<Widget>((fn) => fn()).toList(),
        ),
      ),
    );
  }

最终效果如下:

QQ20190911-160722

最后奉上仓库地址:

github.com/Richard-Cho…