SwiftUI Tips:如何像 ViewBuilder 一样动态返回 Shape

3,967 阅读3分钟

在 SwiftUI 中 shape 是非常常用的元素(比如 RoundedRectangle),但是如果在 view 中你想要把 shape 包装成一个属性却会遇到问题。

以下面的代码为例,某些条件下希望返回的是圆形,有的情况下返回的是圆角矩形。

struct ProblemView: View {
    var flag = Bool.random()
    
    var body: some View {
        shape
            .fill(Color.blue)
            .frame(width: 100, height: 100)
    }
    
    var shape: some Shape {
        if flag {
            return Circle()
        } else {
            return RoundedRectangle(cornerRadius: 10)
        }
    }
}

你会得到下图的错误提示

Image.png

因为 Shape 是一个协议,不是一个具体的类型,some 关键字只能返回一种透明类型。但是示例代码中因为有条件判断,所以编译期间不能确定唯一的具体类型,因此报错了。如果返回的两个 Shape 是同一个具体类型就不会报错了。但是我们今天要解决的就是不同 Shape 如何返回的问题。

转成 View

最懒的方式就是把 Shape 转成 View。转成 View 类型就可以使用 ViewBuilder。

@ViewBuilder
    var shape: some View {
        Group {
            if flag {
               Circle()
            } else {
                RoundedRectangle(cornerRadius: 10)
            }
        }
        .foregroundColor(Color.blue)
    }

使用了 foregroundColor 后类型转成是 View,因此就可以正常使用了。

但是这个方式有很大的限制,因为通常不是所有的场合都能在底层把 Shape 转成 View 类型,也许有的场合上层的的确确需要的是 Shape。

定义 AnyShape

我们可以学习 AnyView 的方式自定义一个 AnyShape,可以包含任意一种 Shape

struct AnyShape: Shape {
    init<S: Shape>(_ wrapped: S) {
        _path = { rect in
            let path = wrapped.path(in: rect)
            return path
        }
    }

    func path(in rect: CGRect) -> Path {
        return _path(rect)
    }

    private let _path: (CGRect) -> Path
}

有了 AnyShape 后,就可以把返回的 Shape 全都包在 AnyShape 里。

var shape: some Shape {
        if flag {
            return AnyShape(Circle())
        } else {
            return AnyShape(RoundedRectangle(cornerRadius: 10))
        }
    }

这个方式的缺点首先写起来有一点小麻烦,其次是这种方式擦除了类型。擦除类型后性能会变差一点点,如果某些场景上层需要判断具体的 Shape 类型也做不到了。

学习 ViewBuilder:自定义 ShapeBuilder

有些聪明的宝宝可能会有一个疑惑了,为什么可以动态的返回 View?因为加了 @ViewBuilder 后,本质上储存的是一个闭包,一段代码(DSL),最后在 ViewBuilder 里会执行这个闭包,返回一个具体类型的 View。对于编译器而言就是一个具体类型的 View 了。

那么我们可不可以学习 ViewBuilder,自定义一个类似功能的 ShapeBuilder 呢?答案是可以的,Swift 提供了自定义 @resultBuilder 的方式。

在 GitHub 上已经有人实现了一个 ShapeBuilder,引入依赖或者拷贝源码到本地后就可以像 ViewBuilder 一样使用了:

@ShapeBuilder
    var shape: some Shape {
        if flag {
            Circle()
        } else {
            RoundedRectangle(cornerRadius: 10)
        }
    }

非常丝滑!

我们看一下核心的源码:

@resultBuilder
public enum ShapeBuilder {
  public static func buildBlock<S: Shape>(_ builder: S) -> some Shape {
    builder
  }
}

public extension ShapeBuilder {
  static func buildOptional<S: Shape>(_ component: S?) -> EitherShape<S, EmptyShape> {
    component.flatMap(EitherShape.first) ?? EitherShape.second(EmptyShape())
  }

  static func buildEither<First: Shape, Second: Shape>(first component: First) -> EitherShape<First, Second> {
    .first(component)
  }

  static func buildEither<First: Shape, Second: Shape>(second component: Second) -> EitherShape<First, Second> {
    .second(component)
  }
}

public enum EitherShape<First: Shape, Second: Shape>: Shape {
  case first(First)
  case second(Second)

  public func path(in rect: CGRect) -> Path {
    switch self {
    case let .first(first):
      return first.path(in: rect)
    case let .second(second):
      return second.path(in: rect)
    }
  }
}

在自定义的 ShapeBuilder 中,实现了 buildOptionalbuildEither 方法,因此支持在 ShapeBuilder 中返回 Optional,支持 if-else 条件语句。

大家都知道 SwiftUI 的 DSL 语法是受限的,如果要在 ShapeBuilder 中支持返回多个 Shape或者支持 for-in 这种语法就需要自己扩展了。

Image.png

如果想深入了解 resultBuilder 可以看看这份文档 Apple 0289 Result Builder