这是一个示例,允许使用手柄进行水平和垂直调整大小。拖动紫色手柄可水平缩放,橙色手柄可垂直缩放。垂直和水平尺寸均受设备分辨率的限制。红色窗格始终可见,但可以使用切换隐藏手柄和其他窗格。还有一个重置按钮可供恢复,仅在更改原始状态时可见。还有其他实用且内联注释的小贴士。
// 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
}
}
@ViewBuilder
内容,而不仅仅是视图数组。这部分并非必需,但我建议使用它,因为它使代码更易读,更符合SwiftUI的风格。Text("左上角")
占屏幕宽度的20%,第一个 SeparatedStack(.horizontal)
占屏幕高度的60%?我感觉我们在 SwiftUI 的能力边缘,所以也许不可能。 - cannyboyid: \.self
应该没问题(尽管有些危险,因为如果您更改了部分的数量,动画可能会看起来更糟)。 - George这是我一直在使用的方法。我有一个通用的SplitView,其中包含使用ViewBuilders创建的primary
(P)和secondary
(V)视图。 fraction
标识打开时主/次视图宽度或高度的比例。我使用secondaryHidden
强制将主视图设置为完整宽度,除了Splitter
的visibleThickness
的一半宽度。 invisibleThickness
是Splitter
可抓取的宽度/高度。 使用SizePreferenceKey
和带有清晰背景的GeometryReader
捕获SplitView
的overallSize
,以便正确应用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
后,我使用HSplitView
和VSplitView
作为公共入口点。
/// 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
,其上有一个可见的带有 visibleThickness
的 RoundedRectangle
,放置在一个透明的 Color
上面,该透明部分的厚度为 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
}
}
以下是一个示例。还有一点需要注意的是,当SplitViews包含其他的SplitViews时,我必须为Splitter使用zIndex。这是因为多个Splitter与相邻视图的主/次要重叠会阻止拖动手势被检测到。在简单情况下不必指定。
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 }
)
}
)
}
)
}
}
而结果...
overallSize
为零相关的运行时警告。当这种情况发生时,比如在视图刚开始设置时,会导致分割内部视图的宽度/高度被设置为负值。我在width()
、height()
和其他几个地方的计算中添加了max
包装器,以防止视图尺寸变成负数。 - Mark Krenek
DragGesture
添加到Divider
中,并手动实现所需的操作,例如根据方向增加框架高度或宽度。 - lorem ipsum