Skip to content

Commit

Permalink
Introduce @State property wrapper based state management (breaking)
Browse files Browse the repository at this point in the history
Replaces the old protocol requirement based state handling. I've tried
my best to make migration as painless as possible with descriptive
warnings and fix-its when SwiftCrossUI detects outdated state
properties.
  • Loading branch information
stackotter committed Jan 9, 2025
1 parent c1b964f commit 1f0092a
Show file tree
Hide file tree
Showing 48 changed files with 462 additions and 379 deletions.
25 changes: 12 additions & 13 deletions Examples/Sources/ControlsExample/ControlsApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,15 @@ import SwiftCrossUI
#endif

class ControlsState: Observable {
@Observed var count = 0
@Observed var exampleButtonState = false
@Observed var exampleSwitchState = false
@Observed var sliderValue = 5.0
}

@main
@HotReloadable
struct ControlsApp: App {
let state = ControlsState()
@State var count = 0
@State var exampleButtonState = false
@State var exampleSwitchState = false
@State var sliderValue = 5.0

var body: some Scene {
WindowGroup("ControlsApp") {
Expand All @@ -24,32 +23,32 @@ struct ControlsApp: App {
VStack {
Text("Button")
Button("Click me!") {
state.count += 1
count += 1
}
Text("Count: \(state.count)")
Text("Count: \(count)")
}
.padding(.bottom, 20)

VStack {
Text("Toggle button")
Toggle("Toggle me!", active: state.$exampleButtonState)
Toggle("Toggle me!", active: $exampleButtonState)
.toggleStyle(.button)
Text("Currently enabled: \(state.exampleButtonState)")
Text("Currently enabled: \(exampleButtonState)")
}
.padding(.bottom, 20)

VStack {
Text("Toggle switch")
Toggle("Toggle me:", active: state.$exampleSwitchState)
Toggle("Toggle me:", active: $exampleSwitchState)
.toggleStyle(.switch)
Text("Currently enabled: \(state.exampleSwitchState)")
Text("Currently enabled: \(exampleSwitchState)")
}

VStack {
Text("Slider")
Slider(state.$sliderValue, minimum: 0, maximum: 10)
Slider($sliderValue, minimum: 0, maximum: 10)
.frame(maxWidth: 200)
Text("Value: \(String(format: "%.02f", state.sliderValue))")
Text("Value: \(String(format: "%.02f", sliderValue))")
}
}
}
Expand Down
14 changes: 5 additions & 9 deletions Examples/Sources/CounterExample/CounterApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,21 @@ import SwiftCrossUI
import SwiftBundlerRuntime
#endif

class CounterState: Observable {
@Observed var count = 0
}

@main
@HotReloadable
struct CounterApp: App {
let state = CounterState()
@State var count = 0

var body: some Scene {
WindowGroup("CounterExample: \(state.count)") {
WindowGroup("CounterExample: \(count)") {
#hotReloadable {
HStack(spacing: 20) {
Button("-") {
state.count -= 1
count -= 1
}
Text("Count: \(state.count)")
Text("Count: \(count)")
Button("+") {
state.count += 1
count += 1
}
}
.padding()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,41 +5,37 @@ import SwiftCrossUI
import SwiftBundlerRuntime
#endif

class GreetingGeneratorState: Observable {
@Observed var name = ""
@Observed var greetings: [String] = []
}

@main
@HotReloadable
struct GreetingGeneratorApp: App {
let state = GreetingGeneratorState()
@State var name = ""
@State var greetings: [String] = []

var body: some Scene {
WindowGroup("Greeting Generator") {
#hotReloadable {
VStack {
TextField("Name", state.$name)
TextField("Name", $name)
HStack {
Button("Generate") {
state.greetings.append("Hello, \(state.name)!")
greetings.append("Hello, \(name)!")
}
Button("Reset") {
state.greetings = []
state.name = ""
greetings = []
name = ""
}
}

if let latest = state.greetings.last {
if let latest = greetings.last {
Text(latest)
.padding(.top, 5)

if state.greetings.count > 1 {
if greetings.count > 1 {
Text("History:")
.padding(.top, 20)

ScrollView {
ForEach(state.greetings.reversed()[1...]) { greeting in
ForEach(greetings.reversed()[1...]) { greeting in
Text(greeting)
}
}
Expand Down
22 changes: 9 additions & 13 deletions Examples/Sources/NavigationExample/NavigationApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,24 +21,20 @@ enum HumanitiesSubject: Codable {
case history
}

class NavigationAppState: Observable {
@Observed var path = NavigationPath()
}

@main
@HotReloadable
struct NavigationApp: App {
let state = NavigationAppState()
@State var path = NavigationPath()

var body: some Scene {
WindowGroup("Navigation") {
#hotReloadable {
NavigationStack(path: state.$path) {
NavigationStack(path: $path) {
Text("Learn about subject areas")
.padding(.bottom, 10)

NavigationLink("Science", value: SubjectArea.science, path: state.$path)
NavigationLink("Humanities", value: SubjectArea.humanities, path: state.$path)
NavigationLink("Science", value: SubjectArea.science, path: $path)
NavigationLink("Humanities", value: SubjectArea.humanities, path: $path)
}
.navigationDestination(for: SubjectArea.self) { area in
switch area {
Expand All @@ -47,17 +43,17 @@ struct NavigationApp: App {
.padding(.bottom, 10)

NavigationLink(
"Physics", value: ScienceSubject.physics, path: state.$path)
"Physics", value: ScienceSubject.physics, path: $path)
NavigationLink(
"Chemistry", value: ScienceSubject.chemistry, path: state.$path)
"Chemistry", value: ScienceSubject.chemistry, path: $path)
case .humanities:
Text("Choose a humanities subject")
.padding(.bottom, 10)

NavigationLink(
"English", value: HumanitiesSubject.english, path: state.$path)
"English", value: HumanitiesSubject.english, path: $path)
NavigationLink(
"History", value: HumanitiesSubject.history, path: state.$path)
"History", value: HumanitiesSubject.history, path: $path)
}

backButton
Expand Down Expand Up @@ -91,7 +87,7 @@ struct NavigationApp: App {
@ViewBuilder
var backButton: some View {
Button("Back") {
state.path.removeLast()
path.removeLast()
}
.padding(.top, 10)
}
Expand Down
47 changes: 20 additions & 27 deletions Examples/Sources/NotesExample/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ struct Note: Codable, Equatable {
var content: String
}

class NotesState: Observable, Codable {
@Observed
var notes: [Note] = [
struct ContentView: View {
let notesFile = URL(fileURLWithPath: "notes.json")

@State var notes: [Note] = [
Note(title: "Hello, world!", content: "Welcome SwiftCrossNotes!"),
Note(
title: "Shopping list",
Expand All @@ -21,25 +22,17 @@ class NotesState: Observable, Codable {
),
]

@Observed
var selectedNoteId: UUID?

@Observed
var error: String?
}

struct ContentView: View {
let notesFile = URL(fileURLWithPath: "notes.json")
@State var selectedNoteId: UUID?

var state = NotesState()
@State var error: String?

var selectedNote: Binding<Note>? {
guard let id = state.selectedNoteId else {
guard let id = selectedNoteId else {
return nil
}

guard
let index = state.notes.firstIndex(where: { note in
let index = notes.firstIndex(where: { note in
note.id == id
})
else {
Expand All @@ -49,40 +42,40 @@ struct ContentView: View {
// TODO: This is unsafe, index could change/not exist anymore
return Binding(
get: {
state.notes[index]
notes[index]
},
set: { newValue in
state.notes[index] = newValue
notes[index] = newValue
}
)
}

var body: some View {
NavigationSplitView {
VStack {
ForEach(state.notes) { note in
ForEach(notes) { note in
Button(note.title) {
state.selectedNoteId = note.id
selectedNoteId = note.id
}
}
Spacer()
if let error = state.error {
if let error = error {
Text(error)
.foregroundColor(.red)
}
Button("New note") {
let note = Note(title: "Untitled", content: "")
state.notes.append(note)
state.selectedNoteId = note.id
notes.append(note)
selectedNoteId = note.id
}
}
.onChange(of: state.notes) {
.onChange(of: notes) {
do {
let data = try JSONEncoder().encode(state.notes)
let data = try JSONEncoder().encode(notes)
try data.write(to: notesFile)
} catch {
print("Error: \(error)")
state.error = "Failed to save notes"
self.error = "Failed to save notes"
}
}
.onAppear {
Expand All @@ -92,10 +85,10 @@ struct ContentView: View {

do {
let data = try Data(contentsOf: notesFile)
state.notes = try JSONDecoder().decode([Note].self, from: data)
notes = try JSONDecoder().decode([Note].self, from: data)
} catch {
print("Error: \(error)")
state.error = "Failed to load notes"
self.error = "Failed to load notes"
}
}
.padding(10)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,6 @@ import SwiftCrossUI
import SwiftBundlerRuntime
#endif

class RandomNumberGeneratorState: Observable {
@Observed var minNum = 0
@Observed var maxNum = 100
@Observed var randomNumber = 0
@Observed var colorOption: ColorOption? = ColorOption.red
}

enum ColorOption: String, CaseIterable {
case red
case green
Expand All @@ -32,22 +25,25 @@ enum ColorOption: String, CaseIterable {
@main
@HotReloadable
struct RandomNumberGeneratorApp: App {
let state = RandomNumberGeneratorState()
@State var minNum = 0
@State var maxNum = 100
@State var randomNumber = 0
@State var colorOption: ColorOption? = ColorOption.red

var body: some Scene {
WindowGroup("Random Number Generator") {
#hotReloadable {
VStack {
Text("Random Number: \(state.randomNumber)")
Text("Random Number: \(randomNumber)")
Button("Generate") {
state.randomNumber = Int.random(in: Int(state.minNum)...Int(state.maxNum))
randomNumber = Int.random(in: Int(minNum)...Int(maxNum))
}

Text("Minimum:")
Slider(
state.$minNum.onChange { newValue in
if newValue > state.maxNum {
state.minNum = state.maxNum
$minNum.onChange { newValue in
if newValue > maxNum {
minNum = maxNum
}
},
minimum: 0,
Expand All @@ -56,9 +52,9 @@ struct RandomNumberGeneratorApp: App {

Text("Maximum:")
Slider(
state.$maxNum.onChange { newValue in
if newValue < state.minNum {
state.maxNum = state.minNum
$maxNum.onChange { newValue in
if newValue < minNum {
maxNum = minNum
}
},
minimum: 0,
Expand All @@ -67,11 +63,11 @@ struct RandomNumberGeneratorApp: App {

HStack {
Text("Choose a color:")
Picker(of: ColorOption.allCases, selection: state.$colorOption)
Picker(of: ColorOption.allCases, selection: $colorOption)
}
}
.padding(10)
.foregroundColor(state.colorOption?.color ?? .red)
.foregroundColor(colorOption?.color ?? .red)
}
}
.defaultSize(width: 500, height: 0)
Expand Down
Loading

0 comments on commit 1f0092a

Please sign in to comment.