iOS 15.6+ MacCatalyst 15.6+ MacCatalyst 12.4+ Mastodon: @stevengharris@mastodon.social

SplitView

Split、HSplit 和 VSplit 视图以及相关的修饰符使你能够

动机

NavigationSplitView 非常适合侧边栏以及符合良好主-细节类型模型的应用程序。另一方面,有时您只需要两个视图并排或上下放置,并调整它们之间的分割。您可能还希望以在您自己的应用程序上下文中合理的方式组合拆分视图。

演示

SplitView

此演示在 Demo 目录中以 SplitDemo.xcodeproj 提供。

用法

安装软件包。

注意: 您也可以使用软件包附带的 .split.vSplit.hSplit 视图修饰符来创建 Split、VSplit 和 HSplit 视图,如果这对您来说更有意义。 请参阅 样式 中的讨论。

创建 Split、HSplit 或 VSplit 视图后,您可以在它们上使用视图修饰符来

在其最简单的形式中,HSplit 和 VSplit 视图看起来像这样

HSplit(left: { Color.red }, right: { Color.green })
VSplit(top: { Color.red }, bottom: { Color.green })

HSplit 是一个水平拆分视图,在左侧的红色和右侧的绿色之间均匀分割。VSplit 是一个垂直拆分视图,在顶部的红色和底部的绿色之间均匀分割。这两个视图都在它们之间使用一个默认的 splitter,可以拖动它来更改红色和绿色视图的大小。

如果您想设置 splitter 的初始位置,可以使用 fraction 修饰符。这是它与 VSplit 视图一起使用的情况

VSplit(top: { Color.red }, bottom: { Color.green })
    .fraction(0.25)

现在您得到一个红色视图在绿色视图之上,顶部占据窗口的 1/4。

通常您想隐藏/显示您拆分的视图之一。您可以通过指定要隐藏的侧面来做到这一点。 使用 SplitSide 指定侧面。对于 HSplit 视图,您可以使用 .left.right 标识侧面。对于 VSplit 视图,您可以使用 .top.bottom。对于 Split 视图(布局可以更改),使用 .primary.secondary。 实际上,.left.top.primary 都是同义词,可以互换使用。 同样,.right.bottom.secondary 也是同义词。

这是一个 HSplit 视图,它在打开时隐藏右侧

HSplit(left: { Color.red }, right: { Color.green })
    .fraction(0.25)
    .hide(.right)

绿色侧面将被隐藏,但您可以使用右侧可见的 splitter 将其拉开。 但这通常不是您想要的。 通常您希望您的用户能够控制侧面是否隐藏。 为此,请传递 SideHolder ObservableObject,它持有您要隐藏的侧面。 同样,SplitView 软件包附带 FractionHolder 和 LayoutHolder。 在底层,Split 视图观察所有这些 holder,并在它们更改时重新绘制自身。

这是一个示例,展示了如何将 SideHolder 与 Button 一起使用来隐藏/显示右侧(绿色)侧

struct ContentView: View {
    let hide = SideHolder()         // By default, don't hide any side
    var body: some View {
        VStack(spacing: 0) {
            Button("Toggle Hide") {
                withAnimation {
                    hide.toggle()   // Toggle between hiding nothing and hiding right
                }
            }
            HSplit(left: { Color.red }, right: { Color.green })
                .hide(hide)
        }
    }
}

请注意,hide 修饰符接受 SplitSide 或 SideHolder。 同样,layout 可以作为 SplitLayout - .horizontal.vertical - 或作为 LayoutHolder 传递。 并且 fraction 可以作为 CGFloat 或 FractionHolder 传递。

hide 上的 toggle() 方法默认切换 secondary 侧的隐藏/显示状态。 如果您想切换特定侧的隐藏/显示状态,请显式使用 toggle(.primary)toggle(.secondary)。(请注意,.primary.left.top 是同义词;.secondary.right.bottom 是同义词。)

嵌套拆分视图

拆分视图本身可以拆分。 这是一个示例,其中 HSplit 的右侧是一个 VSplit,其底部有一个 HSplit

struct ContentView: View {
    var body: some View {
        HSplit(
            left: { Color.green },
            right: {
                VSplit(
                    top: { Color.red },
                    bottom: {
                        HSplit(
                            left: { Color.blue },
                            right: { Color.yellow }
                        )
                    }
                )
            }
        )
    }
}

这是一个 HSplit 包含两个 VSplits 的示例

struct ContentView: View {
    var body: some View {
        HSplit(
            left: { 
                VSplit(top: { Color.red }, bottom: { Color.green })
            },
            right: {
                VSplit(top: { Color.yellow }, bottom: { Color.blue })
            }
        )
    }
}

使用 UserDefaults 进行拆分状态

三个 holder - SideHolder、LayoutHolder 和 FractionHolder - 都带有一个静态方法来返回从 UserDefaults.standard 获取/设置其状态的实例。 让我们扩展前面的示例,使其能够更改 layouthide 状态,并从 UserDefaults 获取/设置它们的值。 请注意,如果您想调整 layout,您需要使用 Split 视图,而不是 HSplit 或 VSplit。 我们通过指定 primarysecondary 视图来创建 Split 视图。 当 LayoutHolder (layout) 持有的 SplitLayout 为 .horizontal 时,primary 视图在左侧,secondary 视图在右侧。 当 SplitLayout 切换为 vertical 时,primary 视图在顶部,secondary 视图在底部。

struct ContentView: View {
    let fraction = FractionHolder.usingUserDefaults(0.5, key: "myFraction")
    let layout = LayoutHolder.usingUserDefaults(.horizontal, key: "myLayout")
    let hide = SideHolder.usingUserDefaults(key: "mySide")
    var body: some View {
        VStack(spacing: 0) {
            HStack {
                Button("Toggle Layout") {
                    withAnimation {
                        layout.toggle()
                    }
                }
                Button("Toggle Hide") {
                    withAnimation {
                        hide.toggle()
                    }
                }
            }
            Split(primary: { Color.red }, secondary: { Color.green })
                .fraction(fraction)
                .layout(layout)
                .hide(hide)
        }
    }
}

第一次打开它时,侧面将 50-50 分割,但是当您拖动 splitter 时,fraction 状态也保留在 UserDefaults.standard 中。 您可以更改 layout 并隐藏/显示绿色视图,并且当您下次打开应用程序时,fractionhidelayout 都将恢复到您离开它们时的状态。

修改和约束默认 Splitter

您可以使用 styling 修饰符更改默认 Splitter 的显示方式。 例如,您可以更改颜色、插图和粗细

HSplit(left: { Color.red }, right: { Color.green })
    .fraction(0.25)
    .styling(color: Color.cyan, inset: 4, visibleThickness: 8)

如果您希望 splitter 在您隐藏侧面时也隐藏,您可以在 styling 修饰符中将 hideSplitter 设置为 true。 例如

HSplit(left: { Color.red }, right: { Color.green })
    .styling(hideSplitter: true)

请注意,如果您将 hideSplitter 设置为 true,则需要包含一种让用户在视图隐藏后取消隐藏视图的方法,例如隐藏/显示按钮。 这是因为 splitter 本身根本不显示,因此您无法仅将其从侧面拖出。

默认情况下,splitter 可以跨拆分视图的完整宽度/高度拖动。 constraints 修饰符允许您约束“primary”和/或“secondary”视图占用的整体视图的最小 faction,因此 splitter 始终保持在这些约束范围内。 您可以通过指定 minPFraction 和/或 minSFraction 来做到这一点。 minPFraction 指的是 HSplit 中的左侧和 VSplit 中的顶部,而 minSFraction 指的是 HSplit 中的右侧和 VSplit 中的底部

HSplit(left: { Color.red }, right: { Color.green })
    .fraction(0.3)
    .constraints(minPFraction: 0.2, minSFraction: 0.2)

拖动以隐藏

当您约束 primary 或 secondary 侧面的 fraction 时,您可能希望在您拖动超过约束时自动隐藏侧面。 但是,我们需要在您拖动“远超”约束时触发此拖动以隐藏行为,因为否则,很难将 splitter 定位在约束处而不隐藏它。 因此,拆分视图将“远超”定义为“超过约束一半以上”。

拖动以隐藏可能是一个很好的快捷方式,可以避免必须按按钮来隐藏侧面。 您可以在 Xcode 中看到它的一个示例,当您将中间编辑器区域和右侧 Inspector 之间的 splitter 拖动到超出 Xcode 对 Inspector 宽度施加的约束时。 在 Xcode 中,当您拖动以隐藏编辑器区域和 Inspector 之间的 splitter 时,您无法将其拖回,因为 splitter 本身已隐藏。 您需要一个按钮来调用隐藏/显示操作,如 先前 所讨论的。 当 hideSplittertrue 时,使用拆分视图进行拖动以隐藏也是如此。

当您的光标移动到超出约束侧面的一半点时,拆分视图会预览当侧面隐藏时的外观。 这样,您就可以直观地指示侧面将隐藏,并且您可以拖回以避免隐藏它。 如果您的拖动在侧面隐藏时结束,则它将保持隐藏状态。

请注意,当您使用拖动以隐藏时,splitter 在侧面隐藏时可能会或可能不会隐藏(取决于 SplitStyling 中 hideSplitter 是否为 true)。 如果您释放超过一半点,拆分视图将显示的外观预览反映了您对 hideSplitter 的设置选择。

要使用拖动以隐藏,请将 dragToHideP 和/或 dragToHideS 添加到您的 constraints 定义中。 例如,以下内容会将拖动约束在宽度的 20% 到 80% 之间,但是当拖动手势在右侧 90% 标记处或之后结束时,辅助侧面将隐藏。 另请注意,在这种情况下,primary 侧面不使用拖动以隐藏

HSplit(left: { Color.red }, right: { Color.green })
    .constraints(minPFraction: 0.2, minSFraction: 0.2, dragToHideS: true)

自定义 Splitter

默认情况下,Split、HSplit 和 VSplit 视图都使用默认的 Splitter 视图。 您可以创建自己的并使用它。 您的自定义 splitter 应符合 SplitDivider 协议,这确保您的自定义 splitter 可以让 Split 视图知道其 styling 是什么。 styling.visibleThickness 是您的自定义 splitter 显示自身的大小,它还定义了 Split 视图内部 primarysecondary 视图之间的间距。

Split 视图检测 splitter 中发生的拖动事件。 因此,如果 styling.visibleThickness 太小而无法正确检测拖动事件,您可能希望使用带有底层 Color.clear 的 ZStack,它代表 styling.invisibleThickness

这是一个自定义 splitter 示例,其内容对观察到的 layouthide 状态敏感

struct CustomSplitter: SplitDivider {
    @ObservedObject var layout: LayoutHolder
    @ObservedObject var hide: SideHolder
    @ObservedObject var styling: SplitStyling
    /// The `hideButton` state tells whether the custom splitter hides the button that normally shows
    /// in the middle. If `styling.previewHide` is true, then we only want to show the button if
    /// `styling.hideSplitter` is also true.
    /// In general, people using a custom splitter need to handle the layout when `previewHide`
    /// is triggered and that layout may depend on whether `hideSplitter` is `true`.
    @State private var hideButton: Bool = false
    let hideRight = Image(systemName: "arrowtriangle.right.square")
    let hideLeft = Image(systemName: "arrowtriangle.left.square")
    let hideDown = Image(systemName: "arrowtriangle.down.square")
    let hideUp = Image(systemName: "arrowtriangle.up.square")
    
    var body: some View {
        if layout.isHorizontal {
            ZStack {
                Color.clear
                    .frame(width: 30)
                    .padding(0)
                if !hideButton {
                    Button(
                        action: { withAnimation { hide.toggle() } },
                        label: {
                            hide.side == nil ? hideRight.imageScale(.large) : hideLeft.imageScale(.large)
                        }
                    )
                    .buttonStyle(.borderless)
                }
            }
            .contentShape(Rectangle())
            .onChange(of: styling.previewHide) { hide in
                hideButton = styling.hideSplitter
            }
        } else {
            ZStack {
                Color.clear
                    .frame(height: 30)
                    .padding(0)
                if !hideButton {
                    Button(
                        action: { withAnimation { hide.toggle() } },
                        label: {
                            hide.side == nil ? hideDown.imageScale(.large) : hideUp.imageScale(.large)
                        }
                    )
                    .buttonStyle(.borderless)
                }
            }
            .contentShape(Rectangle())
            .onChange(of: styling.previewHide) { hide in
                hideButton = styling.hideSplitter
            }
        }
    }}

您可以像这样使用 CustomSplitter

struct ContentView: View {
    let layout = LayoutHolder()
    let hide = SideHolder()
    let styling = SplitStyling(visibleThickness: 20)
    var body: some View {
        Split(primary: { Color.red }, secondary: { Color.green })
            .layout(layout)
            .hide(hide)
            .splitter { CustomSplitter(layout: layout, hide: hide, styling: styling) }
    }
}

如果您制作了一个自定义 splitter,它对人们来说通常很有用,请考虑为 Splitter+Extensions.swift 中的其他 Splitter 扩展提交拉取请求。 line Splitter 包含在该文件中,作为“侧边栏”演示中使用的示例。 同样,invisible Splitter 通过传递零 visibleThickness 来重用 line splitter,并在“不可见 splitter”演示中使用。

不可见 Splitter

您可能希望您拆分的视图可以使用 splitter 进行调整,但 splitter 本身是不可见的。 例如,“正常”侧边栏在其自身和与其相邻的详细信息视图之间不显示 splitter。 您可以通过传递 Splitter.invisible() 作为自定义 splitter 来做到这一点。

使用不可见 splitter 时需要注意的一件事是,当侧面隐藏时,没有视觉指示表明它可以被拖回。 为了防止此问题,您应该在使用 Splitter.invisible() 时指定 minPFractionminSFraction

struct ContentView: View {
    let hide = SideHolder()
    var body: some View {
        VStack(spacing: 0) {
            Button("Toggle Hide") {
                withAnimation {
                    hide.toggle()   // Toggle between hiding nothing and hiding secondary
                }
            }
            HSplit(left: { Color.red }, right: { Color.green })
                .hide(hide)
                .constraints(minPFraction: 0.2, minSFraction: 0.2)
                .splitter { Splitter.invisible() }
        }
    }
}

监控和响应 Splitter 移动

您可以为拆分视图指定一个回调,以便在您拖动 splitter 时执行。 回调报告正在跟踪的 privateFraction; 即,左/顶部侧面占用的完整宽度/高度的 fraction。 使用任何拆分视图的 onDrag(_:) 修饰符指定回调。

这是一个 DemoSlider 的示例,它使用 onDrag(_:) 修饰符来更新 Text 视图,显示每一侧占用的百分比。

struct DemoSlider: View {
    @State private var privateFraction: CGFloat = 0.5
    var body: some View {
        HSplit(
            left: {
                ZStack {
                    Color.green
                    Text(percentString(for: .left))
                }
            },
            right: {
                ZStack {
                    Color.red
                    Text(percentString(for: .right))
                }
            }
        )
        .onDrag { fraction in privateFraction = fraction }
        .frame(width: 400, height: 30)
    }

    /// Return a string indicating the percentage occupied by `side`
    func percentString(for side: SplitSide) -> String {
        var percent: Int
        if side.isPrimary {
            percent = Int(round(100 * privateFraction))
        } else {
            percent = Int(round(100 * (1 - privateFraction)))
        }
        // Empty string if the side will be too small to show it
        return percent < 10 ? "" : "\(percent)%"
    }
}

它看起来像这样

DemoSlider

优先考虑侧面的大小

当您想使用 HSplit 视图进行侧边栏类型的排列时,您通常希望侧边栏在您调整整体视图大小时保持其宽度。 您可能对 VSplit 也有同样的需求。 如果您有两个侧边栏,您可能希望滑动其中一个,而相对的侧边栏保持相同的宽度。 您可以通过在 constraints 修饰符中指定 priority 侧面(.left/.right.top/.bottom)来实现此目的。

这是一个示例,其中包含一个红色左侧边栏和一个绿色右侧边栏,它们围绕着一个黄色中间视图。 当您拖动任一 splitter 时,另一个保持固定。 在底层,Split 视图正在调整 primarysecondary 之间的比例,以使 splitter 保持在同一位置。 您还将看到,当您调整窗口大小时,两个侧边栏都保持其宽度。

struct ContentView: View {
    var body: some View {
        HSplit(
            left: { Color.red },
            right: {
                HSplit(
                    left: { Color.yellow },
                    right: { Color.green }
                )
                .fraction(0.75)
                .constraints(priority: .right)
            }
        )
        .fraction(0.2)
        .constraints(priority: .left)
    }
}

请注意,在上面的示例中,两个侧边栏具有相同的宽度,即总宽度的 0.2,即使为左侧和右侧指定的 fraction 分别为 0.2 和 0.75。 这是因为外部 HSplit 的左侧是总宽度的 0.2,剩下 0.8 在内部 HSplit 中划分。 内部 HSplit 的左侧是 0.75*0.8 或总宽度的 0.6,剩下内部 HSplit 的右侧是总宽度的 0.2。

实现

这里实现的核心是 Split 视图。 VSplit 和 HSplit 实际上是围绕 Split 的便利性和清晰度包装器。 大多数人可能不需要动态调整布局,这实际上是直接使用 Split 的唯一原因。

虽然最终 Split 必须处理宽度和高度,但调整布局的数学原理是相同的,无论其 primary 在左侧还是顶部,其 secondary 在右侧还是底部。

Split 视图中更改的主要状态部分是 constrainedFraction。 这是 primary 视图占用的总宽度/高度的 fraction。 它随着您拖动 splitter 而变化。 当您隐藏/显示时,它不会更改,因为它保留了在再次显示隐藏视图时恢复到所需状态的状态。 Split 视图监视其大小的变化。 当其包含视图的大小更改时(例如,在 Mac 上调整窗口大小或嵌套在另一个 splitter 被拖动的 Split 视图中),大小会更改。

Split、HSplit 和 VSplit 这三个视图都支持相同的修饰符来调整 fractionhidestylingconstraintsonDragsplitter。 Split 视图还具有 layout 的修饰符(HSplit 和 VSplit 也使用它)以及 HSplit 和 VSplit 使用的一些便利修饰符。

样式

在全力以赴使用 View 修饰符样式来为在其上调用的任何 View 返回单个 Split 类型的视图之后,我阅读了 John Sundell 的一篇文章,该文章说明了视图修饰符创建不同容器视图时相关的一些“有问题的”问题。 因此,我重新考虑了我的方法。 我仍然广泛使用视图修饰符,但现在它们在显式的 Split、HSplit 或 VSplit 容器上操作,并且始终返回它们修改的相同类型的视图。 我认为这最终使用法更加清晰。

如果您更喜欢 View 修饰符来启动 Split、HSplit 或 VSplit 创建的想法,您仍然可以使用

Color.green.hSplit { Color.red }   // Returns an HSplit
Color.green.vSplit { Color.red }   // Returns a VSplit
Color.green.split { Color.red }    // Returns a Split

而不是

HSplit(left: { Color.green }, right: { Color.red } )
VSplit(top: { Color.green }, bottom: { Color.red } )
Split(primary: { Color.green }, secondary: { Color.red })

问题

  1. 在 MacOS 14.0 Sonoma 之前的版本中,当拖动 Splitter 以导致视图大小在仅 Mac Catalyst 上变为零时,会出现看似无害的日志消息。 该消息在 Xcode 控制台中显示为 [API] cannot add handler to 3 from 3 - dropping。 此消息在 MacOS 14.0 Sonoma 中不存在。

  2. 用于在 Mac Catalyst 和 MacOS 上显示调整大小光标的 Splitter 的 onHover 进入操作在使用嵌套拆分视图时可能偶尔不会触发。 我认为这种情况很少发生,不足以成为问题。 当发生这种情况时,当悬停在 splitter 上时,光标不会更改为 resizeLeftRightresizeUpDown,但 splitter 仍然可以拖动。

可能的增强功能

我可能会添加一些东西,但非常乐意接受拉取请求! 例如,类似于 NavigationSplitView 的可以适应设备方向和外形尺寸的拆分视图将非常有用。

历史记录

版本 3.5

版本 3.4

版本 3.3

版本 3.2

版本 3.1

版本 3.0

版本 2.0

版本 1.1

版本 1.0

使布局可调整。 清理并规范化 SplitDemo,包括自定义 splitter 和“不可见”splitter。 更新 README。

版本 0.2

消除使用透明背景和 SizePreferenceKeys。(我的怀疑是它们早期是需要的,因为 GeometryReader 否则会导致不良行为,但在任何情况下现在都不需要它们。) 消除 HSplitView 和 VSplitView,它们本身持有 SplitView。 分层既不必要也没有增加价值,只是明确了正在创建哪种类型的 SplitView。 我得出的结论是,使用 ViewModifiers,相同的表达式实际上更清晰、更简洁。 我还添加了 Example.xcworkspace。

版本 0.1

最初在 回复 https://stackoverflow.com/q/67403140 中发布。 此版本使用 HSplitView 和 VSplitView 作为创建 SplitView 的一种手段。 它还使用来自透明背景上的 GeometryReader 的 SizePreferenceKeys 来设置大小。 在嵌套的 SplitView 中,我发现这导致“Bound preference ... tried to update multiple times per frame”间歇性地发生,具体取决于视图排列。