如何解决在 SwiftUI 中拖动分隔符
我将如何使用纯 SwiftUI 在视图或 UIViews 之间添加可拖动的分隔线。甚至可以使用 SwiftUI,还是我必须依靠 UIKit?
带有分隔符的示例屏幕:
我在 SwiftUI 文档中找不到这种东西。即使只是足够的信息来完成左上角的两窗格示例也会很有用。
(有人问过类似的问题 here 和 here ,但这些都是 5 岁和 7 岁,并且处理的是 Objective-C / UIKit,而不是 Swift / SwiftUI)
解决方法
这是一个允许使用夹点调整水平和垂直大小的示例。拖动紫色夹点可水平调整大小,垂直拖动橙色夹点。垂直和水平尺寸都受设备分辨率的限制。红色窗格始终可见,但可以使用切换隐藏夹点和其他窗格。还有一个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)
}
)
}
}
,
我决定采用更像 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],geo: geo) + SeparatedStack.dividerWidth / 2 }
return proportions[safe: index] != nil ? edge : 0
}
}
注意:这使用我的 GeorgeElsham/ViewExtractor 来传递 @ViewBuilder
内容,而不仅仅是一组视图。这部分不是必需的,但我推荐它,因为它使代码可读且更像 SwiftUI。
版权声明:本文内容由互联网用户自发贡献,该文观点与技术仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 dio@foxmail.com 举报,一经查实,本站将立刻删除。