如何在 SwiftUI 中平铺图片

发表于

为您每周带来有关 Swift 和 SwiftUI 的精选资讯!

“这题我会!”。我想,看到本文标题时,这恐怕是大多数人的第一反应。尽管图片平铺并非常用功能,但多数开发者仍能轻松掌握其实现方法。通过搜索引擎查询,几乎所有结果都指向同一解决方案 —— 使用 resizable 修饰符。

然而,对于一个功能强大的 UI 框架而言,若某个需求仅有单一解决方案,显然是不够全面的。在本文中,我们将探讨两种不同的图片平铺实现方式,并由此引申出一种在 SwiftUI 中较少使用的 Image 构建方法。

resizable:最常用但未必最好用

在 SwiftUI 中,resizable 修饰符用于设置图像大小调整的模式,它只能应用于 Image 类型。默认情况下,SwiftUI 使用 stretch 模式来缩放图片以填充可用空间。如果我们将模式切换为 tile,SwiftUI 将以图片原始大小在给定空间中进行平铺。

因此,通过 resizable 来平铺图片是目前使用最广泛且最为人知的方式。

Swift
struct TileDemo: View {
  var body: some View {
    Image("tileSmall")
      .resizable(resizingMode: .tile)
  }
}

image-20240726133233535

尽管这种实现方式具有最佳的系统版本兼容性(支持至 iOS 13),但它也存在以下几个明显的局限性:

  • 无法直接调整原始图片尺寸

    由于 resizable 只能应用于 Image 类型,如果我们想在使用该修饰符前调整图片尺寸,目前只能借助非 SwiftUI 原生的方式来实现。

  • 无法仅使用原始图片的特定部分

    与上述原因类似,需要使用非 SwiftUI 的方法才能通过代码来预先截取原始图片中的部分区域。

  • 在非矩形区域中进行图片平铺时,需要结合 maskclipShape 等修饰符一起使用,操作不够直观。

foregroundStyle:更具 SwiftUI 风格的实现

从 iOS 15 开始,SwiftUI 引入了一个新的修饰符 foregroundStyle。它整合了原本需要通过多个修饰符(如 foregroundColorfilloverlay 等)才能实现的操作。这个修饰符接受一个或多个符合 ShapeStyle 协议的实现作为 style,并用它来渲染被应用元素的前景。

查阅 SwiftUI 的文档,我们会发现自 iOS 13 起就提供了一个有趣的 ShapeStyle 实现 —— ImagePaint:一种通过重复图像区域来填充形状的 ShapeStyle

这意味着,只要我们将其用作视图的前景样式,就能实现一种更符合 SwiftUI 直觉、且具备更多控制能力的图片平铺方式。

Swift
extension ShapeStyle where Self == ImagePaint {
    public static func image(_ image: Image, sourceRect: CGRect = CGRect(x: 0, y: 0, width: 1, height: 1), scale: CGFloat = 1) -> ImagePaint
}
  • image:用来平铺的图片
  • sourceRect:用于定义绘制源图像的区域。例如:CGRect(x: 0, y: 0, width: 1, height: 1) 表示绘制整个图像;CGRect(x: 0, y: 0, width: 0.5, height: 0.5) 表示绘制图像的左上四分之一部分。
  • scale:原始图片的缩放比例,1 表示不进行缩放。

通过 foregroundStyle + ImagePaint 的方式,我们可以轻松突破 resizable 方式的限制。

Swift
struct TileDemo: View {
  var body: some View {
    Circle()
      .foregroundStyle(.image(Image("tileSmall")))
  }
}

image-20240726141010146

Swift
struct TileDemo: View {
  var body: some View {
    Text("Hello")
      .font(.system(size: 100, weight: .black))
      .foregroundStyle(
        .image(Image("tileSmall"), scale: 0.2) // 缩小图片
      )
  }
}

image-20240726140759407

如果你对系统兼容性比较敏感,也可以使用 fill 来进行填充,不过此时只能在 Shape 中实现图片平铺效果。

Swift
struct TileDemo: View {
  var body: some View {
    Circle()
      .fill(.image(Image("tileSmall")))
  }
}

用自定义 Image 来平铺 SF Symbol

在 SwiftUI 中,还存在另一种被广泛使用的 Image:SF Symbol。那么,我们能否直接使用它作为图像源来进行平铺呢?

Swift
Image(systemName: "heart")
   .resizable(resizingMode: .tile)

执行上面的代码,在不同的系统版本中你会看到不同的结果。在 iOS 16 中可以看到平铺效果,但在 iOS 15、17、18 中,Symbol 图片都是以拉伸的形式进行渲染的。

image-20240726102318946

如果切换成 foregroundStyle 的方式,虽然可以看到平铺效果,但除了字号和符号变体外,其他对符号的控制(如颜色、渲染模式等)信息都将被忽略。

Swift
struct TileDemo: View {
  var body: some View {
    Circle()
      .foregroundStyle(.image(Image(systemName: "trash")))
      .font(.largeTitle)
      .tint(.red)
      .foregroundColor(.red)
      .symbolRenderingMode(.multicolor)
      .symbolVariant(.slash)
  }
}

image-20240726142512334

那么,我们能否在更多的平台版本上实现对 Symbol 的平铺,同时支持所有的符号控制功能呢?

从 iOS 16 开始,SwiftUI 为 Image 提供了一个非常有趣的构造方法。通过它,我们可以直接在 GraphicsContext 上进行绘制并创建一个 Image 实例。

Swift
extension Image {
    public init(size: CGSize, label: Text? = nil, opaque: Bool = false, colorMode: ColorRenderingMode = .nonLinear, renderer: @escaping (inout GraphicsContext) -> Void)
}

通过这个构造方法,我们可以对需要平铺的 Symbol 进行重新绘制(创建一个新的 Image 实例),从而实现具备完整控制能力的符号平铺。

Swift
struct TileDemo: View {
  var body: some View {
    symbol(size: .init(width: 50, height: 50), name: "pencil.tip.crop.circle.badge.plus", renderingMode: .original)
      .resizable(resizingMode: .tile)
      .font(.system(size: 35))
      .clipShape(Circle())
  }

  func symbol(size: CGSize, name: String, renderingMode: Image.TemplateRenderingMode) -> Image {
    Image(size: size) { context in
      let symbol = Image(systemName: name).renderingMode(renderingMode)
      context.draw(symbol, at: .init(x: size.width / 2, y: size.height / 2), anchor: .center)
    }
  }
}

image-20240726143554887

遗憾的是,通过这种方法创建的 Image 实例在 foregroundStyle + ImagePaint 中使用时会导致系统崩溃。如果仍想通过 ShapeStyle 的方式来实现平铺,就需要借助 ImageRenderer

Swift
struct TileDemo: View {
  @State var image = Image(systemName: "pencil.tip.crop.badge.plus")
  var body: some View {
    Circle()
      .foregroundStyle(.image(image))
      .background(
        VStack {
          let sf = Image(systemName: "pencil.tip.crop.circle.badge.plus")
            .renderingMode(.original)
            .font(.largeTitle)
          sf
            .generateSnapshot(snapshot: $image)
        }
        .hidden()
      )
  }
}

extension View {
  func generateSnapshot(snapshot: Binding<Image>) -> some View {
    task { @MainActor in
      let renderer = ImageRenderer(content: self)
      await MainActor.run {
        renderer.scale = UIScreen.main.scale
      }
      if let image = renderer.uiImage {
        snapshot.wrappedValue = Image(uiImage: image)
      }
    }
  }
}

image-20240726145134402

当然,如果仅想实现对 Symbol 的平铺,使用 VStack + HStack 或者 Grid 等方式都可以方便地完成。但这种自定义 Image 的方式在实际开发场景中还是有很大的应用潜力。

在 SwiftUI 中,有些控件会自动过滤掉开发者提供的完整声明代码,只保留其想保留的部分。例如,swipeActionstabItem 只会保留 TextImage 的声明和部分设置。使用自定义 Image 的方式,我们就可以动态地创建图片来实现一些特殊需求。

下面的代码将在 tabItem 中创建一个可以保留设定风格的自定义 Image

Swift
struct TabDemo: View {
  @State var selection = 1
  var body: some View {
    TabView(selection: $selection) {
      Text("No Style")
        .tabItem {
          Image(systemName: "heart")
            .font(.largeTitle) // 不起作用
            .shadow(radius: 10) // 不起作用
        }
        .tag(1)
      
      Text("Custom Style")
        .tabItem {
          keepStyle(size: .init(width: 50, height: 50), name: "heart", renderingMode: .template)
            .font(.largeTitle)
            .foregroundColor(selection == 2 ? .red : .secondary.opacity(0.5))
        }
        .tag(2)
    }
  }

  func keepStyle(size: CGSize, name: String, renderingMode: Image.TemplateRenderingMode) -> Image {
    Image(size: size) { context in
      let symbol = Image(systemName: name).renderingMode(renderingMode)
      context.addFilter(.shadow(color: .black, radius: 5))
      context.draw(symbol, at: .init(x: size.width / 2, y: size.height / 2), anchor: .center)
    }
  }
}

image-20240726151301420

总结

本文探讨的图片平铺技巧,无论是使用 foregroundStyle 还是自定义 Image,都展示了 SwiftUI 的潜力和灵活性。这些技巧不仅帮助我们突破框架的表面限制,更强调了持续学习 API 变化的重要性。通过掌握和运用这些新工具,开发者可以不断提升对 SwiftUI 的理解,并用原生方式拓展其能力边界。这种探索和实践的过程,正是提高 SwiftUI 应用上限的关键。

每周一晚,与全球开发者同步,掌握 Swift & SwiftUI 最新动向
可随时退订,干净无 spam