Forked from swiftui-lab/alignment-guides-tool.swift
Last active
March 10, 2025 05:01
-
-
Save seanwoodward/74ae15843eca463e70b83f59202134e7 to your computer and use it in GitHub Desktop.
A few updates to such a great example
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// The SwiftUI Lab | |
// Website: https://swiftui-lab.com | |
// Article: https://swiftui-lab.com/alignment-guides | |
import SwiftUI | |
import Observation | |
extension EnvironmentValues { | |
@Entry var alignmentModel: Model = Model() | |
} | |
@Observable | |
class Model { | |
var minimumContainer = true | |
var extendedTouchBar = false | |
var twoPhases = true | |
var addImplicitView = false { | |
didSet { | |
guard !addImplicitView else { return } | |
showImplicit = false | |
} | |
} | |
var showImplicit = false | |
var algn: [AlignmentEnum] = [.center, .center, .center] | |
var delayedAlgn: [AlignmentEnum] = [.center, .center, .center] | |
var frameAlignment: Alignment = .center | |
var stackAlignment: HorizontalAlignment = .leading | |
@available(*, deprecated, message: "not used") | |
func nextAlignment() -> Alignment { | |
switch frameAlignment { | |
case .leading: | |
.center | |
case .center: | |
.trailing | |
default: | |
.leading | |
} | |
} | |
struct Defualts: Equatable { | |
static let minimumContainer = true | |
static let extendedTouchBar = false | |
static let twoPhases = true | |
static let addImplicitView = false | |
static let showImplicit = false | |
static let algn: [AlignmentEnum] = [.center, .center, .center] | |
static let delayedAlgn: [AlignmentEnum] = [.center, .center, .center] | |
static let frameAlignment = Alignment.center | |
static let stackAlignment = HorizontalAlignment.leading | |
static func isDefaults(_ model: Model) -> Bool { | |
model.minimumContainer == Defualts.minimumContainer && | |
model.extendedTouchBar == Defualts.extendedTouchBar && | |
model.twoPhases == Defualts.twoPhases && | |
model.addImplicitView == Defualts.addImplicitView && | |
model.showImplicit == Defualts.showImplicit && | |
model.algn == Defualts.algn && | |
model.delayedAlgn == Defualts.delayedAlgn && | |
model.frameAlignment == Defualts.frameAlignment && | |
model.stackAlignment == Defualts.stackAlignment | |
} | |
} | |
var showResetButton: Bool { | |
!Defualts.isDefaults(self) | |
} | |
func resetToDefaults() { | |
withAnimation { | |
minimumContainer = Defualts.minimumContainer | |
extendedTouchBar = Defualts.extendedTouchBar | |
twoPhases = Defualts.twoPhases | |
addImplicitView = Defualts.addImplicitView | |
showImplicit = Defualts.showImplicit | |
algn = Defualts.algn | |
delayedAlgn = Defualts.delayedAlgn | |
frameAlignment = Defualts.frameAlignment | |
stackAlignment = Defualts.stackAlignment | |
} | |
} | |
} | |
extension Alignment { | |
var asString: String { | |
switch self { | |
case .leading: | |
".leading" | |
case .center: | |
".center" | |
case .trailing: | |
".trailing" | |
default: | |
"unknown" | |
} | |
} | |
} | |
extension HorizontalAlignment { | |
var asString: String { | |
switch self { | |
case .leading: | |
".leading" | |
case .trailing: | |
".trailing" | |
case .center: | |
".center" | |
default: | |
"unknown" | |
} | |
} | |
} | |
extension HorizontalAlignment: @retroactive Hashable { | |
public func hash(into hasher: inout Hasher) { | |
switch self { | |
case .leading: | |
hasher.combine(0) | |
case .center: | |
hasher.combine(1) | |
case .trailing: | |
hasher.combine(2) | |
default: | |
hasher.combine(3) | |
} | |
} | |
} | |
extension Alignment: @retroactive Hashable { | |
public func hash(into hasher: inout Hasher) { | |
switch self { | |
case .leading: | |
hasher.combine(0) | |
case .center: | |
hasher.combine(1) | |
case .trailing: | |
hasher.combine(2) | |
default: | |
hasher.combine(3) | |
} | |
} | |
} | |
struct ContentView: View { | |
var body: some View { | |
Group { | |
if UIDevice.current.userInterfaceIdiom == .pad | |
{ | |
GeometryReader { proxy in | |
VStack(spacing: 0) { | |
Color.clear | |
.frame(maxWidth: .infinity) | |
.frame(height: 1) | |
.background(Color(uiColor: .secondarySystemBackground)) | |
HStack(spacing: 0) { | |
ControlsView() | |
.frame(width: 380) | |
.frame(maxHeight: .infinity, alignment: .center) | |
.layoutPriority(1) | |
.background(Color(uiColor: .secondarySystemBackground)) | |
VStack { | |
DisplayView(width: proxy.size.width - 401) | |
.frame(maxWidth: proxy.size.width - 401) | |
} | |
.frame(height: proxy.size.height - 300) | |
.background(Color(uiColor: .systemBackground)) | |
.clipShape(RoundedRectangle(cornerRadius: 30)) | |
.shadow(radius: 10) | |
.padding(.trailing, 20) | |
Color.clear | |
.frame(maxHeight: .infinity) | |
.frame(width: 1) | |
.background(Color(uiColor: .secondarySystemBackground)) | |
} | |
.frame(height: (proxy.size.height - 300)) | |
VStack { | |
CodeView() | |
.frame(height: 300) | |
.padding(.top) | |
} | |
.frame(width: proxy.size.width, alignment: .center) | |
} | |
.background(Color(uiColor: .secondarySystemBackground)) | |
} | |
} else { | |
VStack(spacing: 30) { | |
Text("I need an iPad to run!") | |
Text("😟").scaleEffect(2) | |
} | |
.font(.largeTitle) | |
} | |
} | |
} | |
} | |
extension Animation { | |
static let alignment = Animation.easeInOut(duration: 0.5) | |
} | |
struct ControlsView: View { | |
@Environment(\.alignmentModel) var model | |
var body: some View { | |
@Bindable var model = model | |
VStack { | |
VStack(spacing: 10) { | |
ZStack { | |
if model.showResetButton { | |
Button("reset", action: model.resetToDefaults) | |
.frame(maxWidth: .infinity, alignment: .trailingLastTextBaseline) | |
.transition(.opacity.combined(with: .move(edge: .trailing))) | |
} | |
Text("Settings") | |
.font(.title) | |
.frame(maxWidth: .infinity, alignment: .centerLastTextBaseline) | |
} | |
Toggle(isOn: $model.minimumContainer, label: { Text("Narrow Container") }) | |
Toggle(isOn: $model.extendedTouchBar, label: { Text("Extended Bar") }) | |
Toggle(isOn: $model.twoPhases, label: { Text("Show in Two Phases") }) | |
Toggle(isOn: $model.addImplicitView, label: { Text("Include Implicitly View") }) | |
if self.model.addImplicitView { | |
Toggle(isOn: $model.showImplicit, label: { Text("Show Implicit Guides") }) | |
//.disabled(!self.model.addImplicitView) | |
} | |
VStack { | |
Text("Frame Alignment") | |
Picker(selection: $model.frameAlignment.animation(.alignment), label: EmptyView()) { | |
Text(".leading").tag(Alignment.leading) | |
Text(".center").tag(Alignment.center) | |
Text(".trailing").tag(Alignment.trailing) | |
} | |
.pickerStyle(SegmentedPickerStyle()) | |
} | |
VStack { | |
Text("Stack Alignment") | |
Picker(selection: $model.stackAlignment.animation(.alignment), label: EmptyView()) { | |
Text(".leading").tag(HorizontalAlignment.leading) | |
Text(".center").tag(HorizontalAlignment.center) | |
Text(".trailing").tag(HorizontalAlignment.trailing) | |
} | |
.pickerStyle(SegmentedPickerStyle()) | |
} | |
} | |
.padding(20) | |
} | |
.frame(alignment: .top) | |
.background(Color(uiColor: .systemBackground)) | |
.clipShape(RoundedRectangle(cornerRadius: 20)) | |
.shadow(radius: 10) | |
.padding(.horizontal, 20) | |
.animation(.alignment, value: model.showResetButton) | |
.animation(.alignment, value: model.addImplicitView) | |
} | |
} | |
struct CodeView: View { | |
@Environment(\.alignmentModel) var model | |
let indent: @Sendable (ViewDimensions) -> CGFloat = { _ in 40 } | |
var body: some View { | |
VStack(alignment: .leading) { | |
VStack(alignment: .leading) { | |
Text("VStack(alignment: \(model.stackAlignment.asString) {") | |
.padding(.top, 5) | |
.frame(alignment: .leading) | |
.alignmentGuide(.leading, computeValue: indent) | |
CodeFragment(idx: 0) | |
CodeFragment(idx: 1) | |
if model.addImplicitView { | |
VStack(alignment: .leading, spacing: 0) { | |
HStack(spacing: 0) { | |
Text("SomeView()") | |
.foregroundColor(.primary) | |
.transition(.opacity) | |
if model.showImplicit { | |
Group { | |
Text(".alignmentGuide(\(model.stackAlignment.asString), computedValue: { d in ") | |
Text("d[\(model.stackAlignment.asString)]").padding(.horizontal, 5) | |
Text(" }") | |
} | |
.foregroundStyle(.secondary) | |
.transition(.opacity) | |
} | |
} | |
.padding(.vertical, 5) | |
} | |
.transition(.opacity) | |
.animation(.alignment, value: model.showImplicit) | |
} | |
CodeFragment(idx: 2) | |
HStack(spacing: 0) { | |
Text("}.frame(alignment: ") | |
Text("\(self.model.frameAlignment.asString)").padding(5) | |
Text(")") | |
} | |
.frame(alignment: .leading) | |
.alignmentGuide(.leading, computeValue: indent) | |
} | |
.padding(20) | |
} | |
.font(Font.custom("Menlo", size: 16)) | |
.background(Color(uiColor: .systemBackground)) | |
.clipShape(RoundedRectangle(cornerRadius: 20)) | |
.shadow(radius: 10) | |
.animation(.alignment, value: model.showImplicit) | |
.animation(.alignment, value: model.addImplicitView) | |
} | |
} | |
struct CodeFragment: View { | |
@Environment(\.alignmentModel) var model: Model | |
var idx: Int | |
var body: some View { | |
VStack(alignment: .leading, spacing: 0) { | |
HStack(spacing: 0) { | |
Text("SomeView()") | |
Text(".alignmentGuide(\(self.model.stackAlignment.asString), computedValue: { d in ") | |
Text("\(self.model.algn[idx].asString)") | |
.padding(5) | |
.background(self.model.algn[idx] != self.model.delayedAlgn[idx] ? Color.yellow : Color.clear) | |
Text(" }") | |
} | |
} | |
} | |
} | |
struct DisplayView: View { | |
@Environment(\.alignmentModel) var model | |
let width: CGFloat | |
var body: some View { | |
VStack { | |
VStack(alignment: model.stackAlignment, spacing: 20) { | |
Block(algn: binding(0)) | |
.frame(width: 250) | |
.alignmentGuide(model.stackAlignment, computeValue: { [delayedAlgn = model.delayedAlgn[0]] d in delayedAlgn.computedValue(d) }) | |
Block(algn: binding(1)) | |
.frame(width: 200) | |
.alignmentGuide(model.stackAlignment, computeValue: { [delayedAlgn = model.delayedAlgn[1]] d in delayedAlgn.computedValue(d) }) | |
if model.addImplicitView { | |
RoundedRectangle(cornerRadius: 8) | |
.fill(Color.gray) | |
.frame(width: 250, height: 50) | |
.overlay(Text("Implicitly Aligned").foregroundColor(.white)) | |
.overlay(Marker(algn: AlignmentEnum.fromHorizontalAlignment(model.stackAlignment)).opacity(0.5)) | |
.transition(.scale.combined(with: .opacity)) | |
} | |
Block(algn: binding(2)).frame(width: 300) | |
.alignmentGuide(model.stackAlignment, computeValue: { [delayedAlgn = model.delayedAlgn[2]] d in delayedAlgn.computedValue(d) }) | |
} | |
.padding(2) | |
} | |
.frame(width: model.minimumContainer ? nil : width, alignment: model.frameAlignment) | |
.border(Color.red) | |
.animation(.alignment, value: model.addImplicitView) | |
.animation(.alignment, value: model.minimumContainer) | |
.animation(.alignment, value: model.frameAlignment) | |
.animation(.alignment, value: model.stackAlignment) | |
} | |
func binding(_ idx: Int) -> Binding<AlignmentEnum> { | |
return Binding<AlignmentEnum>(get: { | |
model.algn[idx] | |
}, set: { v in | |
model.algn[idx] = v | |
let delay = model.twoPhases ? 500 : 0 | |
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(delay)) { | |
withAnimation(.alignment | |
) { | |
model.delayedAlgn[idx] = v | |
} | |
} | |
}) | |
} | |
} | |
struct Block: View { | |
@Binding var algn: AlignmentEnum | |
let a = Animation.alignment | |
var body: some View { | |
let gesture = DragGesture(minimumDistance: 0, coordinateSpace: .local) | |
.onEnded({ v in | |
withAnimation(a) { | |
algn = .rawValue(v.startLocation.x) | |
} | |
}) | |
VStack(spacing: 0) { | |
HStack { | |
AlignButton(label: "L", action: { withAnimation(a) { algn = .leading } }) | |
Spacer() | |
AlignButton(label: "C", action: { withAnimation(a) { algn = .center } }) | |
Spacer() | |
AlignButton(label: "T", action: { withAnimation(a) { algn = .trailing } }) | |
} | |
.padding(5) | |
.padding(.bottom, 20) | |
} | |
.background(RoundedRectangle(cornerRadius: 8).foregroundColor(.gray)) | |
.overlay( | |
TouchBar() | |
.gesture(gesture) | |
) | |
.overlay( | |
Marker(algn: algn) | |
.opacity(0.5) | |
) | |
} | |
} | |
struct TouchBar: View { | |
@Environment(\.alignmentModel) var model | |
@State private var flag = false | |
var body: some View { | |
GeometryReader { proxy in | |
UnevenRoundedRectangle(cornerRadii: .init(topLeading: 0, bottomLeading: 8, bottomTrailing: 8, topTrailing: model.extendedTouchBar ? 8 : 0)) | |
.foregroundColor(.yellow) | |
.frame(width: proxy.size.width + (model.extendedTouchBar ? 100 : 0), height: 20) | |
.offset(x: 0, y: proxy.size.height - 20) | |
.animation(.easeOut(duration: 0.5), value: model.extendedTouchBar) | |
} | |
} | |
} | |
struct AlignButton: View { | |
let label: String | |
let action: () -> () | |
var body: some View { | |
Button(action: action) { | |
Text(label) | |
.foregroundColor(.black) | |
.padding(10) | |
.background( | |
RoundedRectangle(cornerRadius: 8) | |
.foregroundColor(.green) | |
) | |
} | |
} | |
} | |
struct Marker: View { | |
let algn: AlignmentEnum | |
var body: some View { | |
GeometryReader { proxy in | |
MarkerLine().offset(x: algn.value(for: proxy.size.width)) | |
} | |
} | |
} | |
struct MarkerLine: Shape { | |
func path(in rect: CGRect) -> Path { | |
var p = Path() | |
p.move(to: CGPoint(x: 0, y: 0)) | |
p.addLine(to: CGPoint(x: 0, y: rect.maxY)) | |
p = p.strokedPath(.init(lineWidth: 4, lineCap: .round, lineJoin: .bevel, miterLimit: 1, dash: [6, 6], dashPhase: 3)) | |
return p | |
} | |
} | |
enum AlignmentEnum: Hashable, Sendable { | |
case leading | |
case center | |
case trailing | |
case rawValue(CGFloat) | |
var asString: String { | |
switch self { | |
case .leading: "d[.leading]" | |
case .center: "d[.center]" | |
case .trailing: "d[.trailing]" | |
case .rawValue(let v): "\(v)" | |
} | |
} | |
func value(for width: CGFloat) -> CGFloat { | |
switch self { | |
case .leading: 0 | |
case .center: width / 2.0 | |
case .trailing: width | |
case .rawValue(let v): v | |
} | |
} | |
func computedValue(_ d: ViewDimensions) -> CGFloat { | |
switch self { | |
case .leading: d[.leading] | |
case .center: d.width / 2.0 | |
case .trailing: d[.trailing] | |
case .rawValue(let v): v | |
} | |
} | |
static func fromHorizontalAlignment(_ a: HorizontalAlignment) -> AlignmentEnum { | |
switch a { | |
case .leading: .leading | |
case .center: .center | |
case .trailing: .trailing | |
default: .rawValue(0) | |
} | |
} | |
} | |
#Preview(traits: .landscapeLeft) { | |
ContentView() | |
.environment(\.alignmentModel, Model()) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment