微信公众号搜"智元新知"关注
微信扫一扫可直接关注哦!

在 SwiftUI 中拖动分隔符

如何解决在 SwiftUI 中拖动分隔符

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

带有分隔符的示例屏幕:

enter image description here

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

(有人问过类似的问题 herehere ,但这些都是 5 岁和 7 岁,并且处理的是 Objective-C / UIKit,而不是 Swift / SwiftUI)

解决方法

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

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

我决定采用更像 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],geo: geo) + SeparatedStack.dividerWidth / 2 }
        return proportions[safe: index] != nil ? edge : 0
    }
}

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

版权声明:本文内容由互联网用户自发贡献,该文观点与技术仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 dio@foxmail.com 举报,一经查实,本站将立刻删除。