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 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...