繁体   English   中英

SwiftUI 中的拖动分隔符

[英]Drag separators in SwiftUI

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

带有分隔符的示例屏幕:

在此处输入图像描述

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

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

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

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

我决定做一个更像 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")
            }
        }
    }
}

结果:

结果

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

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

这是我一直在使用的。 我有一个通用的拆分视图,其中包含使用 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 ,在具有invisibleThickness的透明Color之上具有可见的RoundedRectanglevisibleThickness

/// 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 的其他 SplitViews 时,我必须为 Splitter 使用 zIndex。 这是因为多个拆分器与相邻视图的主要/次要重叠会阻止检测到拖动手势。 在更简单的情况下没有必要指定。

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

而结果...

具有多种颜色的拆分视图

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

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