在 SwiftUI 中拖动分隔符

can*_*boy 3 draggable ios swift swiftui

我将如何使用纯 SwiftUI 在 Views 或 UIViews 之间添加可拖动的分隔线。甚至可以使用 SwiftUI,还是我必须依靠 UIKit?

带有分隔符的示例屏幕:

在此处输入图片说明

我在 SwiftUI 文档中找不到这种东西。即使只是足够的信息来完成左上角的两窗格示例也会很有用。

这里这里已经问类似的问题,但这些问题已经有 5 年和 7 年的历史了,并且处理的是 Objective-C / UIKit,而不是 Swift / SwiftUI)

Hel*_*bug 9

这是一个允许使用夹点调整水平和垂直大小的示例。拖动紫色夹点可水平调整大小,垂直拖动橙色夹点。垂直和水平尺寸都受设备分辨率的限制。红色窗格始终可见,但可以使用切换隐藏夹点和其他窗格。还有一个reset按钮可以恢复,只有在原来的状态改变时才可见。还有其他有用的花絮和评论内联。

调整大小窗格动画

// 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)
                    }
            )
    }
}
Run Code Online (Sandbox Code Playgroud)


Geo*_*e_E 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")
            }
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

结果:

结果

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

// 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
    }
}
Run Code Online (Sandbox Code Playgroud)

注意:这使用我的GeorgeElsham/ViewExtractor来传递@ViewBuilder内容,而不仅仅是视图数组。这部分不是必需的,但我推荐它,因为它使代码可读并且更像 SwiftUI。