Resizable
Native resizable composition, package imports, and editable Swift source.
Preview


Use it
Import SwiftCN to use CNResizable directly. This complete example also runs in the native component gallery.
import SwiftUI
import SwiftCN
struct ResizableExample: View {
@State private var fraction = 0.35
var body: some View {
VStack(alignment: .leading, spacing: 12) {
CNResizable(fraction: $fraction, classes: "h-[160]") {
Text("Sidebar").tw("p-4 w-full")
} second: { Text("Editor").tw("p-4 w-full") }
}
}
}Native behavior
Only the divider captures drag gestures. It tracks the pointer immediately in a stable coordinate space and preserves the grab offset. Horizontal, vertical, and right-to-left layouts support a normalized binding, minimum fractions, and accessible adjustment. Drag updates suppress inherited animations; release and cancellation clear the grab. Disabled splits ignore dragging and accessible adjustment. Size the container with classes. An active full-span division separates the panes without exposing a drag target over the fold. The saved fraction stays unchanged and returns when the division becomes inactive. Container, axis, and layout-direction changes cancel the current grab. Use native ArrangementView for system-owned pane placement; this component keeps your explicit axis and fraction binding.
Compose with native SwiftUI
See complete native composition examples for direct native controls, caller-owned sheets, navigation, and desktop split views. The examples use the same editable recipes.
Style and compose
Visual recipes use .tw classes. Append classes: to override a recipe locally, or change the recipe in TWGlobalRules.named for your subtree. Native layout and interaction modifiers remain SwiftUI APIs. For control colors, append cn-tint-[primary], another theme token, or an interpolated Color.
Motion recipes are editable too: control-motion uses a 150 ms smooth response, disclosure-motion uses a 250 ms spring, and feedback-motion uses a 200 ms smooth response. Set a recipe to animate-none in your global rules to turn it off. Presets respect Reduce Motion. Static components stay still; native scrolling, drag tracking, and platform presentation keep their own gestures.
Action groups and card, dialog, and questionnaire footers stack when their content does not fit. The native layout preserves the same child controls during this change.
View builders preserve native child content. Native Button roles, labels, keyboard shortcuts, submit actions, and presentation modifiers still work. A string recipe does not replace a native control API or a typed chart mark.
Own the source
Copy the complete file below into your app alongside import SwiftCN. The file contains related composable parts from the same family. Its dependencies use public package APIs, so copied and imported components can coexist.
To own the styling engine too, download the complete source bundle. It contains every component, the .tw engine, its recipes, and the MIT license. Keep those files in one module and remove import SwiftCN from the usage example.
import SwiftUI
import SwiftCN
/// The divider alone owns its gesture; child scroll and control gestures remain native.
/// A normalized fraction supports persisted layout and accessible adjustment on both platforms.
public struct CNResizable<First: View, Second: View>: View {
@Binding private var fraction: Double
private let axis: Axis
private let minimumFraction: Double
private let classes: TWClasses
private let first: First
private let second: Second
@Environment(\.isEnabled) private var isEnabled
@Environment(\.layoutDirection) private var direction
@Namespace private var coordinateSpace
@GestureState private var isDragging = false
@State private var drag: CNResizeDrag?
@State private var dragInvalidated = false
public init(fraction: Binding<Double>, axis: Axis = .horizontal, minimumFraction: Double = 0.15,
classes: TWClasses = "", @ViewBuilder first: () -> First, @ViewBuilder second: () -> Second) {
precondition(minimumFraction.isFinite && (0...0.5).contains(minimumFraction))
_fraction = fraction; self.axis = axis; self.minimumFraction = minimumFraction; self.classes = classes
self.first = first(); self.second = second()
}
public nonisolated static func clamp(_ value: Double, minimum: Double) -> Double {
min(1 - minimum, max(minimum, value.isFinite ? value : 0.5))
}
public var body: some View {
GeometryReader { geometry in
let placement = CNResizePlacement(size: geometry.size, divisions: geometry.cnDivisionRegions,
axis: axis, direction: direction, fraction: Self.clamp(fraction, minimum: minimumFraction), handleExtent: handleExtent)
panels(placement)
.onChange(of: geometry.size) { _, _ in invalidateDrag() }
.onChange(of: placement.divisionStart) { _, _ in invalidateDrag() }
.onChange(of: placement.handle) { _, _ in invalidateDrag() }
}.tw(cn("resizable", classes))
.onChange(of: isDragging) { _, active in
// GestureState also resets after system cancellation, which has no onEnded callback.
if !active { drag = nil; dragInvalidated = false }
}
.onChange(of: isEnabled) { _, enabled in if !enabled { invalidateDrag() } }
.onChange(of: axis) { _, _ in invalidateDrag() }
.onChange(of: direction) { _, _ in invalidateDrag() }
}
private func panels(_ placement: CNResizePlacement) -> some View {
let layout = axis == .horizontal ? AnyLayout(HStackLayout(spacing: 0)) : AnyLayout(VStackLayout(spacing: 0))
return layout {
first.frame(width: axis == .horizontal ? placement.first : nil, height: axis == .vertical ? placement.first : nil)
handle(length: placement.length)
.opacity(placement.divided ? 0 : 1)
.allowsHitTesting(!placement.divided)
.accessibilityHidden(placement.divided)
.frame(width: axis == .horizontal ? placement.handle : nil, height: axis == .vertical ? placement.handle : nil)
second.frame(width: axis == .horizontal ? placement.length - placement.first : nil,
height: axis == .vertical ? placement.length - placement.first : nil)
}.coordinateSpace(name: coordinateSpace)
}
private var handleExtent: CGFloat {
#if os(macOS)
12
#else
44
#endif
}
private func handle(length: CGFloat) -> some View {
ZStack {
Color.clear
Rectangle().tw(axis == .horizontal ? "resizable-handle w-[2]" : "resizable-handle h-[2]")
}.frame(width: axis == .horizontal ? handleExtent : nil, height: axis == .vertical ? handleExtent : nil)
.contentShape(Rectangle())
.gesture(DragGesture(minimumDistance: 0, coordinateSpace: .named(coordinateSpace))
.updating($isDragging) { _, active, transaction in
active = true
transaction.disablesAnimations = true
}.onChanged { value in
guard isEnabled, length > 0, !dragInvalidated else { return }
// The containing split, rather than the moving divider, defines pointer coordinates.
// Capture the grab offset before publishing any binding change.
if drag == nil {
drag = CNResizeDrag(fraction: Self.clamp(fraction, minimum: minimumFraction),
start: position(value.startLocation, length: length), length: length)
}
update(at: value.location, length: length)
}.onEnded { value in
if isEnabled && !dragInvalidated { update(at: value.location, length: length) }
drag = nil; dragInvalidated = false
})
.accessibilityElement().accessibilityLabel("Resize panels").accessibilityValue("\(Int(Self.clamp(fraction, minimum: minimumFraction) * 100)) percent")
.accessibilityAdjustableAction { direction in
guard isEnabled else { return }
fraction = Self.clamp(Self.clamp(fraction, minimum: minimumFraction) +
(direction == .increment ? 0.05 : -0.05), minimum: minimumFraction)
}
}
private func position(_ point: CGPoint, length: CGFloat) -> CGFloat {
CNResizeDrag.position(point, axis: axis, direction: direction, length: length, handleExtent: handleExtent)
}
private func invalidateDrag() {
if isDragging { dragInvalidated = true }
drag = nil
}
private func update(at point: CGPoint, length: CGFloat) {
guard let drag, length > 0 else { return }
var transaction = Transaction(animation: nil)
transaction.disablesAnimations = true
withTransaction(transaction) {
fraction = drag.fraction(at: position(point, length: length), length: length, minimum: minimumFraction)
}
}
}
/// A pointer grab keeps its offset when the container or the divider moves.
struct CNResizeDrag {
private let grabOffset: CGFloat
static func position(_ point: CGPoint, axis: Axis, direction: LayoutDirection,
length: CGFloat, handleExtent: CGFloat) -> CGFloat {
let coordinate = axis == .horizontal ? point.x : point.y
let logical = axis == .horizontal && direction == .rightToLeft ? length + handleExtent - coordinate : coordinate
return logical - handleExtent / 2
}
init(fraction: Double, start: CGFloat, length: CGFloat) {
grabOffset = fraction * length - start
}
func fraction(at position: CGFloat, length: CGFloat, minimum: Double) -> Double {
guard length.isFinite, length > 0, position.isFinite else { return 0.5 }
return CNResizable<EmptyView, EmptyView>.clamp((position + grabOffset) / length, minimum: minimum)
}
}
/// Scalar layout state keeps change tracking independent of platform rectangle conformance.
struct CNResizePlacement: Equatable {
let first: CGFloat
let length: CGFloat
let handle: CGFloat
let divided: Bool
var divisionStart: CGFloat { divided ? first : -1 }
init(size: CGSize, divisions: [CGRect], axis: Axis, direction: LayoutDirection,
fraction: Double, handleExtent: CGFloat) {
let division = CNPlacementRegions.divider(in: CGRect(origin: .zero, size: size), regions: divisions, axis: axis)
divided = division != nil
handle = division.map { axis == .horizontal ? $0.width : $0.height } ?? handleExtent
length = max(0, (axis == .horizontal ? size.width : size.height) - handle)
if let division {
first = axis == .vertical ? division.minY : (direction == .leftToRight ? division.minX : size.width - division.maxX)
} else { first = length * fraction }
}
}