🏁

SwiftUI のプレビュー用の便利背景

2022/11/16に公開

はじめに

Xcode Previews は便利ですが、デザインによっては背景を透過、または逆に透過してはいけないデザイン指定の場合があり、気を抜くとプレビューではそういったところをついつい見逃してしまいます
その都度適当な背景色をセットしては確認していたのですが、少し面倒なのでプレビュー用に便利な背景を作成してみました

完成形は以下のようなイメージです

struct TransparentDemo_Previews: PreviewProvider {
    static var previews: some View {
        Text("Hello world")
            .font(.largeTitle)
            .frame(width: 200, height: 400)
            .demoBackground(withBorder: true)
            .previewLayout(.sizeThatFits)
    }
}

struct TransparentDemo_Previews: PreviewProvider {
    static var previews: some View {
        Text("Hello world")
            .font(.largeTitle)
            .demoBackground()
            .previewLayout(.sizeThatFits)
    }
}

実装

背景を市松模様に

まずは透過画像などでよく利用される市松模様を用意します
サイズによって目が細かくなりすぎないように適当に調整しています

public struct TransparentDemo: View {
    public var body: some View {
        GeometryReader { geometry in
            let width = geometry.size.width
            let unit = max(15, min(width / 20, 120))

            let xCount = Int(width / unit) + 1
            let yCount = Int(geometry.size.height / unit) + 1

            Path { path in
                for x in 0..<xCount {
                    for y in 0..<yCount {
                        switch (x.isMultiple(of: 2), y.isMultiple(of: 2)) {
                        case (true, false): continue
                        case (false, true): continue
                        default: break
                        }
                        path.addRect(.init(x: CGFloat(x) * unit, y: CGFloat(y) * unit,
                                           width: unit, height: unit))
                    }
                }
            }
            .fill(Color.secondary)
            .opacity(0.2)
        }
        .clipped()
    }
}

これだけでも十分ですが、これを書いている途中でサイズ情報も分かったら便利だと思ったので、次にそれを描画します

サイズ情報の付加

サイズはターゲットビューに対して overlay モディファイアでサイズに影響を与えないように別レイヤーで GeometryReader から取得した結果を描画しています

extension View {
    public func demoBackground(withBorder: Bool = false) -> some View {
        self
            .background(
                Rectangle()
                    .strokeBorder(style: .init(lineWidth: 1, dash: [8]))
                    .foregroundColor(Color.secondary.opacity(0.8))
                    .opacity(withBorder ? 1 : 0)
            )
            .overlay(
                // width x height
                GeometryReader { geometry in
                    Rectangle()
                        .fill(Color.clear)
                        .overlay(
                            Text("w\(Int(geometry.size.width)) x h\(Int(geometry.size.height))")
                                .foregroundColor(.red)
                                .font(.system(size: 16))
                                .padding(.top),
                            alignment: .top
                        )
                }
                .alignmentGuide(.bottom) { d in d[.top] },
                alignment: .bottom
            )
            ...

またプレビュー用に見やすくするために余白を加えているため、ターゲットビュー自体のサイズ感が分かりづらい場合があるので、同じように目盛りをそれぞれ描画しています

            ...
            .overlay(
                // vertical scale
                GeometryReader { geometry in
                    Rectangle()
                        .fill(Color.clear)
                        .overlay(
                            Path { path in
                                let h = geometry.size.height
                                let padding = 4 as CGFloat

                                path.move(to: .init(x: -10, y: 0))
                                path.addLine(to: .init(x: 10, y: 0))

                                path.move(to: .init(x: 0, y: padding))
                                path.addLine(to: .init(x: -5, y: 10 + padding))
                                path.move(to: .init(x: 0, y: padding))
                                path.addLine(to: .init(x: 5, y: 10 + padding))

                                path.move(to: .init(x: 0, y: padding))
                                path.addLine(to: .init(x: 0, y: h - padding))

                                path.addLine(to: .init(x: -5, y: h - padding - 10))
                                path.move(to: .init(x: 0, y: h - padding))
                                path.addLine(to: .init(x: 5, y: h - padding - 10))

                                path.move(to: .init(x: -10, y: h))
                                path.addLine(to: .init(x: 10, y: h))
                            }
                            .stroke(style: .init(lineCap: .round, lineJoin: .round))
                            .fill(.red)
                            .padding(.leading),
                            alignment: .leading
                        )
                }
                .alignmentGuide(.trailing) { d in d[.leading] },
                alignment: .trailing
            )

alignmentGuide を利用することでターゲットビューのアライメントに対して位置を指定しているのでどのようなサイズであってもずれずに描画することができます
※ あまりに対象のサイズが小さいと潰れてしまいますが…

おわりに

ちょっとした拡張ですが、 あるとないとでは結構開発効率に違いが出てくる部分だと思うので是非参考にしてみてください!

コード全体
import SwiftUI

public struct TransparentDemo: View {
    public var body: some View {
        GeometryReader { geometry in
            let width = geometry.size.width
            let unit = max(15, min(width / 20, 120))

            let xCount = Int(width / unit) + 1
            let yCount = Int(geometry.size.height / unit) + 1

            Path { path in
                for x in 0..<xCount {
                    for y in 0..<yCount {
                        switch (x.isMultiple(of: 2), y.isMultiple(of: 2)) {
                        case (true, false): continue
                        case (false, true): continue
                        default: break
                        }
                        path.addRect(.init(x: CGFloat(x) * unit, y: CGFloat(y) * unit,
                                           width: unit, height: unit))
                    }
                }
            }
            .fill(Color.secondary)
            .opacity(0.2)
        }
        .clipped()
    }
}

extension View {
    public func demoBackground(withBorder: Bool = false) -> some View {
        self
            .background(
                Rectangle()
                    .strokeBorder(style: .init(lineWidth: 1, dash: [8]))
                    .foregroundColor(Color.secondary.opacity(0.8))
                    .opacity(withBorder ? 1 : 0)
            )
            .overlay(
                // width x height
                GeometryReader { geometry in
                    Rectangle()
                        .fill(Color.clear)
                        .overlay(
                            Text("w\(Int(geometry.size.width)) x h\(Int(geometry.size.height))")
                                .foregroundColor(.red)
                                .font(.system(size: 16))
                                .padding(.top),
                            alignment: .top
                        )
                }
                .alignmentGuide(.bottom) { d in d[.top] },
                alignment: .bottom
            )
            .overlay(
                // vertical scale
                GeometryReader { geometry in
                    Rectangle()
                        .fill(Color.clear)
                        .overlay(
                            Path { path in
                                let h = geometry.size.height
                                let padding = 4 as CGFloat

                                path.move(to: .init(x: -10, y: 0))
                                path.addLine(to: .init(x: 10, y: 0))

                                path.move(to: .init(x: 0, y: padding))
                                path.addLine(to: .init(x: -5, y: 10 + padding))
                                path.move(to: .init(x: 0, y: padding))
                                path.addLine(to: .init(x: 5, y: 10 + padding))

                                path.move(to: .init(x: 0, y: padding))
                                path.addLine(to: .init(x: 0, y: h - padding))

                                path.addLine(to: .init(x: -5, y: h - padding - 10))
                                path.move(to: .init(x: 0, y: h - padding))
                                path.addLine(to: .init(x: 5, y: h - padding - 10))

                                path.move(to: .init(x: -10, y: h))
                                path.addLine(to: .init(x: 10, y: h))
                            }
                            .stroke(style: .init(lineCap: .round, lineJoin: .round))
                            .fill(.red)
                            .padding(.leading),
                            alignment: .leading
                        )
                }
                .alignmentGuide(.trailing) { d in d[.leading] },
                alignment: .trailing
            )
            .overlay(
                // horizontal scale
                GeometryReader { geometry in
                    Rectangle()
                        .fill(Color.clear)
                        .overlay(
                            Path { path in
                                let w = geometry.size.width
                                let padding = 4 as CGFloat

                                path.move(to: .init(x: 0, y: -10))
                                path.addLine(to: .init(x: 0, y: 10))

                                path.move(to: .init(x: padding, y: 0))
                                path.addLine(to: .init(x: 10 + padding, y: -5))
                                path.move(to: .init(x: padding, y: 0))
                                path.addLine(to: .init(x: 10 + padding, y: 5))

                                path.move(to: .init(x: padding, y: 0))
                                path.addLine(to: .init(x: w - padding, y: 0))

                                path.addLine(to: .init(x: w - padding - 10, y: -5))
                                path.addLine(to: .init(x: w - padding, y: 0))
                                path.addLine(to: .init(x: w - padding - 10, y: 5))

                                path.move(to: .init(x: w, y: -10))
                                path.addLine(to: .init(x: w, y: 10))
                            }
                            .stroke(style: .init(lineCap: .round, lineJoin: .round))
                            .fill(.red)
                            .padding(.top),
                            alignment: .bottom
                        )
                }
                .alignmentGuide(.bottom) { d in d[.top] },
                alignment: .bottom
            )
            .padding()
            .padding()
            .padding(.bottom)
            .background(TransparentDemo())
    }
}

Discussion