在SwiftUI中拖动分隔符

4
我该如何在纯SwiftUI中为视图或UIView之间添加可拖动的分隔线?使用SwiftUI是否可能实现,还是必须回到UIKit上?
带有分隔线的示例屏幕:

enter image description here

我在SwiftUI文档中找不到这种东西。即使只提供足够的信息来完成左上角的双面板示例也很有用。
(类似的问题已经被询问这里这里,但这些问题是5年和7年前提出的,涉及Objective-C / UIKit而非Swift / SwiftUI)

1
没有内置的功能,您需要将DragGesture添加到Divider中,并手动实现所需的操作,例如根据方向增加框架高度或宽度。 - lorem ipsum
3个回答

13

这是一个示例,允许使用手柄进行水平和垂直调整大小。拖动紫色手柄可水平缩放,橙色手柄可垂直缩放。垂直和水平尺寸均受设备分辨率的限制。红色窗格始终可见,但可以使用切换隐藏手柄和其他窗格。还有一个重置按钮可供恢复,仅在更改原始状态时可见。还有其他实用且内联注释的小贴士。

ResizePaneAnimation

// Resizable panes, red is always visible
struct PanesView: View {
    static let startWidth = UIScreen.main.bounds.size.width / 6
    static let startHeight = UIScreen.main.bounds.size.height / 5
    // update drag width when the purple grip is dragged
    @State private var dragWidth : CGFloat = startWidth
    // update drag height when the orange grip is dragged
    @State private var dragHeight : CGFloat = startHeight
    // remember show/hide green and blue panes
    @AppStorage("show") var show : Bool = true
    // keeps the panes a reasonable size based on device resolution
    var minWidth : CGFloat = UIScreen.main.bounds.size.width / 6
    let minHeight : CGFloat = UIScreen.main.bounds.size.height / 5
    // purple and orange grips are this thick
    let thickness : CGFloat = 9
    // computed property that shows resize when appropriate
    var showResize : Bool {
        dragWidth != PanesView.startWidth || dragHeight != PanesView.startHeight
    }

    // use computed properties to keep the body tidy
    var body: some View {
        HStack(spacing: 0) {
            redPane
            // why two show-ifs? the animated one chases the non-animated and adds visual interest
            if show {
                purpleGrip
            }
            if show { withAnimation {
                VStack(spacing: 0) {
                    greenPane
                    orangeGrip
                    Color.blue.frame(height: dragHeight) // blue pane
                }
                .frame(width: dragWidth)
            } }
        }
    }
    
    var redPane : some View {
        ZStack(alignment: Alignment(horizontal: .trailing, vertical: .top)) {
            Color.red
            // shows and hides the green and blue pane, both grips
            Toggle(isOn: $show.animation(), label: {
                // change icon depending on toggle position
                Image(systemName: show ? "eye" : "eye.slash")
                    .font(.title)
                    .foregroundColor(.primary)
            })
            .frame(width: 100)
            .padding()
        }
    }
    
    var purpleGrip : some View {
        Color.purple
            .frame(width: thickness)
            .gesture(
                DragGesture()
                    .onChanged { gesture in
                        let screenWidth = UIScreen.main.bounds.size.width
                        // the framework feeds little deltas as the drag continues updating state
                        let delta = gesture.translation.width
                        // make sure drag width stays bounded
                        dragWidth = max(dragWidth - delta, minWidth)
                        dragWidth = min(screenWidth - thickness - minWidth, dragWidth)
                    }
            )
    }
    
    var greenPane : some View {
        ZStack(alignment: Alignment(horizontal: .center, vertical: .top)) {
            Color.green
            // reset to original size
            if showResize { withAnimation {
                Button(action: { withAnimation {
                    dragWidth = UIScreen.main.bounds.size.width / 6
                    dragHeight = UIScreen.main.bounds.size.height / 5
                } }, label: {
                    Image(systemName: "uiwindow.split.2x1")
                        .font(.title)
                        .foregroundColor(.primary)
                        .padding()
                })
                .buttonStyle(PlainButtonStyle())
            }}
        }
    }
    
    var orangeGrip : some View {
        Color.orange
            .frame(height: thickness)
            .gesture(
                DragGesture()
                    .onChanged { gesture in
                        let screenHeight = UIScreen.main.bounds.size.height
                        let delta = gesture.translation.height
                        dragHeight = max(dragHeight - delta, minHeight)
                        dragHeight = min(screenHeight - thickness - minHeight, dragHeight)
                    }
            )
    }
}

谢谢。有没有一种方法可以在旋转设备时保持比例?例如,如果水平手柄线在屏幕中间,那么当设备旋转时,它将始终保持在屏幕的正中间? - cannyboy

8

我决定采用更类似于SwiftUI的方法。它可以是任何大小,因此不固定于整个屏幕大小。可以这样调用:

import SwiftUI
import ViewExtractor


struct ContentView: View {
    var body: some View {
        SeparatedStack(.vertical, ratios: [6, 4]) {
            SeparatedStack(.horizontal, ratios: [2, 8]) {
                Text("Top left")
                
                Text("Top right")
            }
            
            SeparatedStack(.horizontal) {
                Text("Bottom left")
                
                Text("Bottom middle")
                
                Text("Bottom right")
            }
        }
    }
}

结果:

Result

代码(请阅读下面的注释):

// MARK: Extensions
extension Array {
    subscript(safe index: Int) -> Element? {
        guard indices ~= index else { return nil }
        return self[index]
    }
}

extension View {
    @ViewBuilder func `if`<Output: View>(_ condition: Bool, transform: @escaping (Self) -> Output, else: @escaping (Self) -> Output) -> some View {
        if condition {
            transform(self)
        } else {
            `else`(self)
        }
    }
}


// MARK: Directional layout
enum Axes {
    case horizontal
    case vertical
}

private struct EitherStack<Content: View>: View {
    let axes: Axes
    let content: () -> Content
    
    var body: some View {
        switch axes {
        case .horizontal:   HStack(spacing: 0, content: content)
        case .vertical:     VStack(spacing: 0, content: content)
        }
    }
}


// MARK: Stacks
struct SeparatedStack: View {
    static let dividerWidth: CGFloat = 5
    static let minimumWidth: CGFloat = 20
    
    private let axes: Axes
    private let ratios: [CGFloat]?
    private let views: [AnyView]
    
    init<Views>(_ axes: Axes, ratios: [CGFloat]? = nil, @ViewBuilder content: TupleContent<Views>) {
        self.axes = axes
        self.ratios = ratios
        views = ViewExtractor.getViews(from: content)
    }
    
    var body: some View {
        GeometryReader { geo in
            Color.clear
                .overlay(SeparatedStackInternal(views: views, geo: geo, axes: axes, ratios: ratios))
        }
    }
}


// MARK: Stacks (internal)
private struct SeparatedStackInternal: View {
    private struct GapBetween: Equatable {
        let gap: CGFloat
        let difference: CGFloat?
        
        static func == (lhs: GapBetween, rhs: GapBetween) -> Bool {
            lhs.gap == rhs.gap && lhs.difference == rhs.difference
        }
    }
    
    @State private var dividerProportions: [CGFloat]
    @State private var lastProportions: [CGFloat]
    private let views: [AnyView]
    private let geo: GeometryProxy
    private let axes: Axes
    
    init(views: [AnyView], geo: GeometryProxy, axes: Axes, ratios: [CGFloat]?) {
        self.views = views
        self.geo = geo
        self.axes = axes
        
        // Set initial proportions
        if let ratios = ratios {
            guard ratios.count == views.count else {
                fatalError("Mismatching ratios array size. Should be same length as number of views.")
            }
            
            let total = ratios.reduce(0, +)
            var proportions: [CGFloat] = []
            for index in 0 ..< ratios.count - 1 {
                let ratioTotal = ratios.prefix(through: index).reduce(0, +)
                proportions.append(ratioTotal / total)
            }
            
            _dividerProportions = State(initialValue: proportions)
            _lastProportions = State(initialValue: proportions)
        } else {
            let range = 1 ..< views.count
            let new = range.map { index in
                CGFloat(index) / CGFloat(views.count)
            }
            _dividerProportions = State(initialValue: new)
            _lastProportions = State(initialValue: new)
        }
    }
    
    var body: some View {
        EitherStack(axes: axes) {
            ForEach(views.indices) { index in
                if index != 0 {
                    Color.gray
                        .if(axes == .horizontal) {
                            $0.frame(width: SeparatedStack.dividerWidth)
                        } else: {
                            $0.frame(height: SeparatedStack.dividerWidth)
                        }
                }
                
                let gapAtIndex = gapBetween(index: index)
                
                views[index]
                    .if(axes == .horizontal) {
                        $0.frame(maxWidth: gapAtIndex.gap)
                    } else: {
                        $0.frame(maxHeight: gapAtIndex.gap)
                    }
                    .onChange(of: gapAtIndex) { _ in
                        if let difference = gapBetween(index: index).difference {
                            if dividerProportions.indices ~= index - 1 {
                                dividerProportions[index - 1] -= difference / Self.maxSize(axes: axes, geo: geo)
                                lastProportions[index - 1] = dividerProportions[index - 1]
                            }
                        }
                    }
            }
        }
        .overlay(overlay(geo: geo))
    }
    
    @ViewBuilder private func overlay(geo: GeometryProxy) -> some View {
        ZStack {
            ForEach(dividerProportions.indices) { index in
                Color(white: 0, opacity: 0.0001)
                    .if(axes == .horizontal) { $0
                        .frame(width: SeparatedStack.dividerWidth)
                        .position(x: lastProportions[index] * Self.maxSize(axes: axes, geo: geo))
                    } else: { $0
                        .frame(height: SeparatedStack.dividerWidth)
                        .position(y: lastProportions[index] * Self.maxSize(axes: axes, geo: geo))
                    }
                    .gesture(
                        DragGesture()
                            .onChanged { drag in
                                let translation = axes == .horizontal ? drag.translation.width : drag.translation.height
                                let currentPosition = lastProportions[index] * Self.maxSize(axes: axes, geo: geo) + translation
                                let offset = SeparatedStack.dividerWidth / 2 + SeparatedStack.minimumWidth
                                let minPos = highEdge(of: lastProportions, index: index - 1) + offset
                                let maxPos = lowEdge(of: lastProportions, index: index + 1) - offset
                                let newPosition = min(max(currentPosition, minPos), maxPos)
                                dividerProportions[index] = newPosition / Self.maxSize(axes: axes, geo: geo)
                            }
                            .onEnded { drag in
                                lastProportions[index] = dividerProportions[index]
                            }
                    )
            }
        }
        .if(axes == .horizontal) {
            $0.offset(y: geo.size.height / 2)
        } else: {
            $0.offset(x: geo.size.width / 2)
        }
    }
    
    private static func maxSize(axes: Axes, geo: GeometryProxy) -> CGFloat {
        switch axes {
        case .horizontal:   return geo.size.width
        case .vertical:     return geo.size.height
        }
    }
    
    private func gapBetween(index: Int) -> GapBetween {
        let low = lowEdge(of: dividerProportions, index: index)
        let high = highEdge(of: dividerProportions, index: index - 1)
        let gap = max(low - high, SeparatedStack.minimumWidth)
        let difference = gap == SeparatedStack.minimumWidth ? SeparatedStack.minimumWidth - low + high : nil
        return GapBetween(gap: gap, difference: difference)
    }
    
    private func lowEdge(of proportions: [CGFloat], index: Int) -> CGFloat {
        var edge: CGFloat { proportions[index] * Self.maxSize(axes: axes, geo: geo) - SeparatedStack.dividerWidth / 2 }
        return proportions[safe: index] != nil ? edge : Self.maxSize(axes: axes, geo: geo)
    }
    
    private func highEdge(of proportions: [CGFloat], index: Int) -> CGFloat {
        var edge: CGFloat { proportions[index] * Self.maxSize(axes: axes, geo: geo) + SeparatedStack.dividerWidth / 2 }
        return proportions[safe: index] != nil ? edge : 0
    }
}

注意:这里使用了我的GeorgeElsham/ViewExtractor,以便能够传递@ViewBuilder内容,而不仅仅是视图数组。这部分并非必需,但我建议使用它,因为它使代码更易读,更符合SwiftUI的风格。

同样的问题也问@Helperbug。我注意到当我将iPad旋转成横向时,底部会被裁剪掉。 - cannyboy
@cannyboy 好的,那现在应该都修好了! - George
谢谢,这样就不会被截断了,但比例没有保持。例如,在您的示例中,水平分隔符从中间开始。但旋转时,它超过了一半的位置(它与顶部保持相同的像素距离)。如何保持比例?一个相关的问题:如何以特定的比例开始?例如,Text("左上角") 占屏幕宽度的20%,第一个 SeparatedStack(.horizontal) 占屏幕高度的60%?我感觉我们在 SwiftUI 的能力边缘,所以也许不可能。 - cannyboy
1
刚看到上面的评论。解决方案是跟踪比例而不是绝对位置。祝贺@George_E提供了一个可靠的解决方案! - Helperbug
谢谢您。有两个问题。1)它无法与您的ViewExtractor v3配合使用。2)XCode现在警告各种索引的非常数范围。 - Confused Vorlon
@ConfusedVorlon 1) 嗯,是的,这应该是一个相对简单的代码更改。我很乐意让答案被编辑。2) 添加 id: \.self 应该没问题(尽管有些危险,因为如果您更改了部分的数量,动画可能会看起来更糟)。 - George

4

这是我一直在使用的方法。我有一个通用的SplitView,其中包含使用ViewBuilders创建的primary(P)和secondary(V)视图。 fraction标识打开时主/次视图宽度或高度的比例。我使用secondaryHidden强制将主视图设置为完整宽度,除了SplittervisibleThickness的一半宽度。 invisibleThicknessSplitter可抓取的宽度/高度。 使用SizePreferenceKey和带有清晰背景的GeometryReader捕获SplitViewoverallSize,以便正确应用fraction

fileprivate struct SplitView<P: View, S: View>: View {
    private let layout: Layout
    private let zIndex: Double
    @Binding var fraction: CGFloat
    @Binding var secondaryHidden: Bool
    private let primary: P
    private let secondary: S
    private let visibleThickness: CGFloat = 2
    private let invisibleThickness: CGFloat = 30
    @State var overallSize: CGSize = .zero
    @State var primaryWidth: CGFloat?
    @State var primaryHeight: CGFloat?

    var hDrag: some Gesture {
        // As we drag the Splitter horizontally, adjust the primaryWidth and recalculate fraction
        DragGesture()
            .onChanged { gesture in
                primaryWidth = gesture.location.x
                fraction = gesture.location.x / overallSize.width
            }
    }

    var vDrag: some Gesture {
        // As we drag the Splitter vertically, adjust the primaryHeight and recalculate fraction
        DragGesture()
            .onChanged { gesture in
                primaryHeight = gesture.location.y
                fraction = gesture.location.y / overallSize.height
            }
    }

    enum Layout: CaseIterable {
        /// The orientation of the primary and seconday views (e.g., Vertical = VStack, Horizontal = HStack)
        case Horizontal
        case Vertical
    }

    var body: some View {
        ZStack(alignment: .topLeading) {
            switch layout {
            case .Horizontal:
                // When we init the view, primaryWidth is nil, so we calculate it from the
                // fraction that was passed-in. This lets us specify the location of the Splitter
                // when we instantiate the SplitView.
                let pWidth = primaryWidth ?? width()
                let sWidth = overallSize.width - pWidth - visibleThickness
                primary
                    .frame(width: pWidth)
                secondary
                    .frame(width: sWidth)
                    .offset(x: pWidth + visibleThickness, y: 0)
                Splitter(orientation: .Vertical, visibleThickness: visibleThickness)
                    .frame(width: invisibleThickness, height: overallSize.height)
                    .position(x: pWidth + visibleThickness / 2, y: overallSize.height / 2)
                    .zIndex(zIndex)
                    .gesture(hDrag, including: .all)
            case .Vertical:
                // When we init the view, primaryHeight is nil, so we calculate it from the
                // fraction that was passed-in. This lets us specify the location of the Splitter
                // when we instantiate the SplitView.
                let pHeight = primaryHeight ?? height()
                let sHeight = overallSize.height - pHeight - visibleThickness
                primary
                    .frame(height: pHeight)
                secondary
                    .frame(height: sHeight)
                    .offset(x: 0, y: pHeight + visibleThickness)
                Splitter(orientation: .Horizontal, visibleThickness: visibleThickness)
                    .frame(width: overallSize.width, height: invisibleThickness)
                    .position(x: overallSize.width / 2, y: pHeight + visibleThickness / 2)
                    .zIndex(zIndex)
                    .gesture(vDrag, including: .all)
            }
        }
        .background(GeometryReader { geometry in
            // Track the overallSize using a GeometryReader on the ZStack that contains the
            // primary, secondary, and splitter
            Color.clear
                .preference(key: SizePreferenceKey.self, value: geometry.size)
                .onPreferenceChange(SizePreferenceKey.self) {
                    overallSize = $0
                }
        })
        .contentShape(Rectangle())
    }
    
    init(layout: Layout, zIndex: Double = 0, fraction: Binding<CGFloat>, secondaryHidden: Binding<Bool>, @ViewBuilder primary: (()->P), @ViewBuilder secondary: (()->S)) {
        self.layout = layout
        self.zIndex = zIndex
        _fraction = fraction
        _primaryWidth = State(initialValue: nil)
        _primaryHeight = State(initialValue: nil)
        _secondaryHidden = secondaryHidden
        self.primary = primary()
        self.secondary = secondary()
    }
    
    private func width() -> CGFloat {
        if secondaryHidden {
            return overallSize.width - visibleThickness / 2
        } else {
            return (overallSize.width * fraction) - (visibleThickness / 2)
        }
    }
    
    private func height() -> CGFloat {
        if secondaryHidden {
            return overallSize.height - visibleThickness / 2
        } else {
            return (overallSize.height * fraction) - (visibleThickness / 2)
        }
    }
    
}

fileprivate struct SizePreferenceKey: PreferenceKey {
    static var defaultValue: CGSize = .zero

    static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
        value = nextValue()
    }
}

使用filePrivate SplitView后,我使用HSplitViewVSplitView作为公共入口点。

/// A view containing a primary view and a secondary view layed-out vertically and separated by a draggable horizontally-oriented Splitter
///
/// The primary view is above the secondary view.
struct VSplitView<P: View, S: View>: View {
    let zIndex: Double
    @Binding var fraction: CGFloat
    @Binding var secondaryHidden: Bool
    let primary: ()->P
    let secondary: ()->S
    
    var body: some View {
        SplitView(layout: .Vertical, zIndex: zIndex, fraction: $fraction, secondaryHidden: $secondaryHidden, primary: primary, secondary: secondary)
    }
    
    init(zIndex: Double = 0, fraction: Binding<CGFloat>, secondaryHidden: Binding<Bool>? = nil, @ViewBuilder primary: @escaping (()->P), @ViewBuilder secondary: @escaping (()->S)) {
        self.zIndex = zIndex
        _fraction = fraction
        _secondaryHidden = secondaryHidden ?? .constant(false)
        self.primary = primary
        self.secondary = secondary
    }
}


/// A view containing a primary view and a secondary view layed-out horizontally and separated by a draggable vertically-oriented Splitter
///
/// The primary view is to the left of the secondary view.
struct HSplitView<P: View, S: View>: View {
    let zIndex: Double
    @Binding var fraction: CGFloat
    @Binding var secondaryHidden: Bool
    let primary: ()->P
    let secondary: ()->S
    
    var body: some View {
        SplitView(layout: .Horizontal, fraction: $fraction, secondaryHidden: $secondaryHidden, primary: primary, secondary: secondary)
    }
    
    init(zIndex: Double = 0, fraction: Binding<CGFloat>, secondaryHidden: Binding<Bool>? = nil, @ViewBuilder primary: @escaping (()->P), @ViewBuilder secondary: @escaping (()->S)) {
        self.zIndex = zIndex
        _fraction = fraction
        _secondaryHidden = secondaryHidden ?? .constant(false)
        self.primary = primary
        self.secondary = secondary
    }
}

Splitter 是一个可见的 ZStack,其上有一个可见的带有 visibleThicknessRoundedRectangle,放置在一个透明的 Color 上面,该透明部分的厚度为 invisibleThickness

/// The Splitter that separates the primary from secondary views in a SplitView.
struct Splitter: View {
    
    private let orientation: Orientation
    private let color: Color
    private let inset: CGFloat
    private let visibleThickness: CGFloat
    private var invisibleThickness: CGFloat
    
    enum Orientation: CaseIterable {
        /// The orientation of the Divider itself.
        /// Thus, use Horizontal in a VSplitView and Vertical in an HSplitView
        case Horizontal
        case Vertical
    }
    
    var body: some View {
        ZStack(alignment: .center) {
            switch orientation {
            case .Horizontal:
                Color.clear
                    .frame(height: invisibleThickness)
                    .padding(0)
                RoundedRectangle(cornerRadius: visibleThickness / 2)
                    .fill(color)
                    .frame(height: visibleThickness)
                    .padding(EdgeInsets(top: 0, leading: inset, bottom: 0, trailing: inset))
            case .Vertical:
                Color.clear
                    .frame(width: invisibleThickness)
                    .padding(0)
                RoundedRectangle(cornerRadius: visibleThickness / 2)
                    .fill(color)
                    .frame(width: visibleThickness)
                    .padding(EdgeInsets(top: inset, leading: 0, bottom: inset, trailing: 0))
            }
        }
        .contentShape(Rectangle())
    }
    
    init(orientation: Orientation, color: Color = .gray, inset: CGFloat = 8, visibleThickness: CGFloat = 2, invisibleThickness: CGFloat = 30) {
        self.orientation = orientation
        self.color = color
        self.inset = inset
        self.visibleThickness = visibleThickness
        self.invisibleThickness = invisibleThickness
    }
}

以下是一个示例。还有一点需要注意的是,当SplitViews包含其他的SplitViews时,我必须为Splitter使用zIndex。这是因为多个Splitter与相邻视图的主/次要重叠会阻止拖动手势被检测到。在简单情况下不必指定。

struct ContentView: View {
    var body: some View {
        HSplitView(
            zIndex: 2,
            fraction: .constant(0.5),
            primary: { Color.red },
            secondary: {
                VSplitView(
                    zIndex: 1,
                    fraction: .constant(0.5),
                    primary: { Color.blue },
                    secondary: {
                        HSplitView(
                            zIndex: 0,
                            fraction: .constant(0.5),
                            primary: { Color.green },
                            secondary: { Color.yellow }
                        )
                    }
                )
            }
        )
    }
}

而结果...

带多种颜色的SplitViews


1
这是一个很好的例子。我遇到了一些与overallSize为零相关的运行时警告。当这种情况发生时,比如在视图刚开始设置时,会导致分割内部视图的宽度/高度被设置为负值。我在width()height()和其他几个地方的计算中添加了max包装器,以防止视图尺寸变成负数。 - Mark Krenek
1
谢谢。我已上传一个能够工作的版本(限制了宽度和高度)到 github。它还有几个其他改进,但基本上与上面描述的相同。 - Steve Harris

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接