简体   繁体   中英

Drag separators in SwiftUI

How would I add draggable separator lines between Views or UIViews using purely SwiftUI. Is it even possible with SwiftUI, or would I have to fall back on UIKit?

Example screens with separators:

在此处输入图像描述

I can't find this kind of stuff in the SwiftUI documentation. Even just enough info to do the top-left two-pane example would be useful.

(Similar questions have been asked here and here , but these are 5 and 7 years old, and deal with Objective-C / UIKit, not Swift / SwiftUI)

Here is a sample that allows horizontal and vertical resizing using grips. Dragging the purple grip resizes horizontally and the orange grip vertically. Both vertical and horizontal sizes are bounded by device resolution. The red pane is always visible, but the grips and other panes can be hidden using a toggle. There is also a reset button to restore, it is only visible when the original state changes. There are other tidbits that are useful and commented inline.

ResizePane动画

// 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)
                    }
            )
    }
}

I decided to do a more SwiftUI-like approach. It can be any size so it is not fixed to the whole screen size. It can be called like this:

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:

结果

Code (read notes below):

// 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
    }
}

Note: this uses my GeorgeElsham/ViewExtractor for the ability to pass in @ViewBuilder content, rather than just an array of views. This part is not necessary, however I recommend it because it makes the code readable and more SwiftUI-like.

Here is what I have been using. I have a generic SplitView with a primary (P) and secondary (V) view created using ViewBuilders. The fraction identifies the ratio of primary to secondary width or height at open. I use secondaryHidden to force the primary to full width modulo half of the visibleThickness of the Splitter . The invisibleThickness is the grabbable width/height for the Splitter . The SizePreferenceKey is used with a GeometryReader on a clear background to capture the overallSize of the SplitView so that the fraction can be applied properly.

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()
    }
}

With the filePrivate SplitView in place, I use HSplitView and VSplitView as the public entry points.

/// 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
    }
}

The Splitter is a ZStack with a visible RoundedRectangle with visibleThickness on top of a clear Color with 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
    }
}

Here's an example. One additional note is that I had to use zIndex for the Splitter when SplitViews contained other SplitViews that contain other SplitViews. This is because the because the overlap of the multiple Splitters with the primary/secondary of adjacent views prevents the drag gesture from being detected. It's not necessary to specify in simpler cases.

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 }
                        )
                    }
                )
            }
        )
    }
}

And the result...

具有多种颜色的拆分视图

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM