Swift UI Study Notes

2023/10/24

SwiftUI Study Notes

TOC

State Driven UI

In SwiftUI, view updates are driven by states.

Views are a function of state, not a result of a sequence of events; Any change rebuilds the View and all its children.

struct MyView: View {
    private @State var counter = 0
    var body: some View {
        VStack {
            Text("\(counter)")
            Button("Increment") {
                self.counter += 1
            }
        }
    }
}

Decide what to use:

Back to TOC

Property Wrapper

Property wrapper adds an extra layer of getter and setter for variable.

@propertyWrapper
struct YinYangNumber {
  private var number: Int?

  var wrappedValue: Int? {
    get { number }
    set { number = newValue }
  }

  var projectedValue: YinYang {
    guard let number else {
      return .notDetermined
    }
    return number % 2 == 0 ? .yin : .yang
  }
}

Binding

Binding is a reference of data, in SwiftUI, binding is bi-directional, it allows read/write of data without owning it.

State

@frozen @propertyWrapper public struct State<Value>: DynamicProperty {
  public init(wrappedValue value: Value)
  public init(initialValue value: Value)
  public var wrappedValue: Value { get nonmutating set }
  public var projectedValue: Binding<Value> { get } // this gives the binding referencing this state value
}

Dynamic Property

public protocol DynamicProperty { 
  // called immediately before the view's `body()` function is executed,
  // after updating the values of any dynamic properties stored in `self`
  mutating func update()
}
@propertyWrapper
struct MyState<Value>: DynamicProperty {
  private var _value: Value
  private var _store: MyStore<Value>?

  init(wrappedValue: Value) {
    self._value = wrappedValue
    self._store = MyStore(value: wrappedValue)
  }

  var wrappedValue: Value {
    get { _store?.value ?? _value }
    mutating set { _store.value = newValue }
  }

  var projectedValue: Binding<Value> {
    Binding<Value>(
      get: { self.wrappedValue },
      set: { self._store?.value = $0 }
    )
  }

  func update() {}
}

Single Source of Truth

      ┌────────────────────────┐
      │                        │
      │                        ▼
┌───────────┐           ┌────────────┐
│  Actions  │           │ Dispatcher │
└───────────┘           └────────────┘
      ▲                        │
      │                        │
      │                     action
      │                        │
      │                        ▼
      │      ┌────┐     ┌────────────┐
   action────│ UI │     │  Reducer   │◀─┐
             └────┘     └────────────┘  │
                ▲              │        │
                │              │        │
                │              │     state
             update            ▼        │
                │       ┌────────────┐  │
                └───────│   State    │──┘
                        └────────────┘

Implementation:

Problems

As Store grows and the number of Views increases, the reponsiveness of the app may decrease. Solution:

  1. the timing of the injection ob ObservedObject
  2. complexity of View
  3. single source of truth: anything in Store changes will lead to all views that listen to state changes to update

Timing of Dependency Injection

Output of the following code:

MainView
SubView
MainView
SubView
…
struct MainView: View {
  @ObservedObject var viewModel = ViewModel()

  var body: some View {
    print("MainView")
    return Form {
      SubView(date: $viewModel.date)
      Button("Update Date") {
        self.viewModel.updateDate()
      }
    }
  }
}

struct SubView: View {
  @Binding var date: String
  var body: some View {
    print("SubView")
    return Text(date)
  }
}

class ViewModel: ObservableObject {
  @Published var date: String = Date().description
  func updateDate() {
    date = Date().description
  }
}

Complexity of View

We tend to construct complex view using smaller views instead of having all the components inside one single view.

Single source of truth means a small change of state will cause view update to all listeners, the more states in Store and the more frequent they are updated, the more chance View are getting updated.

class Store: ObservableObject {
  @Published var state = State()
  // …
}

struct State {
  var username: String = ""
  var login: Bool = false
}

Solution: views to send action to store and keep a reference to store.

let store = Store()

var body: some View {
  Button("Increment") {
    store.send(.increment)
  }
}

struct OtherView: View {
  @Binding var store: Store
  var body: some View {
    Text(store.state.name)
  }
}

Local Optimization

@Environment(\.myKey) var currentPage



Button("update") {
  self.currentPage += 1
}

SubView()
  .environment(\.myKey, currentPage)

EnvironmentObject can also be injected at view branch bue due to its reference type, any change at branch level will cause whole View tree to update.

Use other SwiftUI property wrapper

struct SideView: View {
  @Environment(\.managedObjectContext)
  @State var search: Search?

  var body: some View {
    VStack(alignment: .leading) {
      SearchView(onSearch: self.onSearch)
      InsideListView(fetchRequest: makeFetchRequest())
    }
  }

  private func makeSearchRequest() -> FetchRequest<Book> {
    // …
  }

  private func onSearch(_ search: Search) {
    if search.text.count < 3 && search.type != Constants.all && search.app != .all) {
      self.search = nil
    } else {
      self.search = search
    }
  }
}

Get Rid of Observed Object

What we want to achieve:

class Store { // no longer ObservedObject
  var state = AppState()
  // …
}

struct AppState {
  var name = CurrentValueSubject<String, Never>("Name")
  var age = CurrentValueSubject<Int, Never>(100)
}

// Inject
struct ContentView: View {
  @State var name: String = ""
  var body: some View {
    Form {
      Text(name)
    }
    .onReceive(store.state.name, perform: { self.name = $0 })
  }
}

// update
extension Store {
  func test() {
    state.name.value = "NewName"
  }
}

// binding
// TextField("Name", text: store.state.name.binding)
extension CurrentValueSubject {
  var binding: Binding<Output> {
    Binding<Output>(get: { self.value }, set: {self.value = $0 })
  }
}

// binding for struct
struct Student {
  var name = "foo"
  var age = 18
}

struct AppState {
  var student = CurrentValueSubject<Student, Never>(Student())
}

// TextField("Student name:", text: store.state.student.binding(for: \.name))
extension CurrentValueSubject {
  func bninding<Value>(for keyPath: WriteableKeyPath<Output, Value>) -> Binding<Value> {
    Binding<Value>(
      get: { self.value[keyPath: keyPath] },
      set: { self.value[keyPath: keyPath] = $0 }
    )
  }
}

// binding with logic
struct ContentView: View {
  @MyState<String>(wrappedValue: String(store.state.student.value.age), toAction: {
    store.state.student.value.age = Int($0) ?? 0
  }) var studentAge

  var body: some View {
    TextField("student age", text: $studentAge)
  }
}

FocusedBinding

A convenience property wrapper for observing and automatically unwrapping state bindings from the focused view or one of its ancestors.

FocusedValue property wrapper allows us to observe the value from the focused view or one of its ancestors, it observes the view hierarchy’s focused view.

struct FocusedMessageKey: FocusedValueKey {
  typealias Value = Binding<String>
}

extension FocusedValues {
  var message: Binding<String>? {
    get { self[FocusedMessageKey.self] }
    set { self[FocusedMessageKey.self] = newValue }
  }
}

struct ShowView: View {
  @FocusedValue(\.message) var focusedMessage
  // @FocusedBinding(\.message) var focusedMessage1

  var body: some View {
    VStack {
      Text("focused: \(focusedMessage?.wrappedValue ?? "")")
      // Text("focused: \(focusedMessaage1 ?? "")")
    }
  }
}

struct InputView1: View {
  @State private var text = ""
  var body: some View {
    VStack {
      TextField("input1:", text: $text)
        .focusedValue(\.message, $text)
    }
  }
}

struct InputView2: View {
  @State private var text = ""
  var body: some View {
    VStack {
      TextField("input1:", text: $text)
        .focusedValue(\.message, $text)
    }
  }
}

AttributedString

AttributedString lets you specify attributes of string at character level.

var attributedString = AttributedString("Hello, world!")
let hello = attributedString.range(of: "Hello,")!
attributedString[hello].inlinePresentationIntent = .stronglyEmphasized
let world = attributedString.range(of: "world!")!
attributedString[world].link = URL(string: "https://www.world.com")!

Since Swift 3.0, Text has native support for AttributedString:

var goodbyeAttributedString: AttributedString {
  var good = AttributedString("good")
  good.font = .title.bold()
  good.foregroundColor = .red
  var bye = AttributedString("bye")
  bye.font = .callout
  bye.foregroundColor = .orange
  return good + bye
}

Text(goodbyeAttributedString)

AttributedStringKey

AttributedStringKey defines the name and type of AttributedString’s properties, accessible via dot or key path.

var string = AttributedString("goodbye")
string.font = .body
let font = string[keyPath: \.font]

We can create our own attributes:

enum OutlineColorAttribute: AttributedStringKey {
  typealias Value = Color
  static let name = "OutlineColor"
}

string.outlineColor = .purple

AttributeContainer

AttributeContainer is a container that lets you sets multiple attributes of string.

var attributedString = AttributedString("Banned")
string.foregroundColor = .red

var container = AttributeContainer()
container.inlinePresentationIntent = .strikethrough
container.font = .body
container.backgroundColor = .blue
container.foregroundColor = .yellow

attributedString.setAttributes(container)

You can also replace and merge attributes:

var existing = AttributeContainer()
existing.foregroundColor = .cyan
existing.font = .body

var new = AttributeContainer()
new.foregroundColor = .red
new.link = URL(string: "https://www.some.link")!

attributedString.replaceAttributes(existing, with: new)
attributedString.mergeAttributes(newContainer, mergePolicy: .keepNew)

Attribute Scope

Currently, AttributedString has 5 predefined scopes:

When XCode can’t determine the scope of attribute, we can explicitly specify it:

uiKitString.uiKit.foregroundColor = .red
appKitString.appKit.backgroundColor = .yellow

Attributes with the same name but belong to different scopes are not interchangeable:

aString.swiftUI.foregroundColor = .orange
aString.uiKit.foregroundColor = .orange
aString.appKit.foregroundColor = .orange

// conversion to NSAttributedString can specify scope
let nsString = try! NSAttributedString(attributedString, including: \.uiKit)

A number of attributes can be set at base level:

attributedString.inlinePresentationIntent = .stronglyEmphasized

Characters and Scalars

AttributeString exposes characters and unicode scalars for its internal string.

var attributedString = AttributedString("SwiftUI👍")
attributedString.characters.count //
attributedString.unicodeScalars.count //
String(attributedString.characters)
let range = attributedString.range(of: "SwiftUI")!
attributedString.characters.replaceSubrange(range, with: "UIKit")
// UIKit👍

Runs

A view of AttributedString’s attributes, each Run corresponds to a partial string with the same attributes.

// collect all attributes via run
var allKeysContainer = AttributeContainer()
for run in attributedString.runs {
  let container = run.attributes
  allKeysContainer.merge(container)
}
// transform attributes
attributedString.transformingAttributes(\.foregroundColor, \.font) { color, font in
  if color.value == .yellow && font.value == .title {
    attributedString[color.range].background = .green
  }
}

Localisation

Localised AttributedString displays the text for current system language, we can’t ask it to display for specific language.

replacementIndex

We can set index for localized string interpolation.

var world = AttributedString(localized: "world \("") \("")", options: .applyReplacementIndexAttribute)

for (index, range) in world.runs[\.replacementIndex] {
  switch index {
    case 1:
    world[range].baselineOffset = 20
    world[range].font = .title
    case 2:
    world[range].backgroundColor = .blue
    default:
    world[range].inlinePresentationIntent = .strikethrough
  }
}

Markdown symbol

We can use Run to customize markdown.

var markdownString = AttributedString(localized: "**hello** ~world~ _!_")
for (inlineIntent, range) in markdownString.runs[\.inlinePresentationIntent] {
  guard let inlineIntent = inlineIntent else {
    continue
  }
  switch inlineIntent {
  case .stronglyEmphasized:
    markdownString[range].foregroundColor = .red
  case .emphasized:
    markdownString[range].foregroundColor = .green
  case .strikethrough:
    markdownString[range].foregroundColor = .blue
  }
}

Custom Attributes

With custom attributes we can further customise the attributed string to suit our needs.

  1. Create custom AttributedStringKey
  2. Create custom AttributeScope
  3. Extend AttributeDynamicLookup
struct MyIDKey: AttributedStringKey {
  typealias Value = Int // this need to be Hashable
  static var name: String = "id"
}

extension AttributeScopes {
  public struct MyScope: AttributeScope {
    let id: MyIDKey
    let swiftUI: SwiftUIAttributes // scope
  }

  var myScope: MyScope.Type {
    MyScope.self
  }
}

extension AttributeDynamicLookup {
  subscript<T>(dynamicMember keyPath: KeyPath<AttributeScopes.MyScope, T>) -> T where T: AttributedStringKey {
    self[T.self]
  }
}

Now we can use the id property:

var attributedString = AttributedString("Goodbye world!")
attributedString.id = 24

AttributedString vs NSAttributedString

While AttributedString is merely a Swift version of NSAttributedString, there are a number of differences between the two.

Value Type vs Reference Type

AttributedString is a value type, whereas NSAttributedString is a reference type.

NSAttributedString:

let good = NSMutableAttributedString("good")
let buy = NSAttributedString("bye")
good.append(bye)

AttributedString:

var good = AttributedString("good")
let bye = AttributedString("bye")
good.append(bye)

Type Check and Safety

AttributedString has properties for attributes, whereas NSAttributeString uses a dictionary to update attributes. AttributedString is safer in term of attributes type check.

Localisaiton

AttributedString has support for localisation.

var localizableString = AttributedString(localized: "Goodbye for now at \(Date.now,format: .dateTime)!",locale: Locale(identifier: "en-au"),option:.applyReplacementIndexAttribute)

Formatter

The Formatter API introduced in 2021 supports formatting for AttributedString.

var dateString: AttributedString {
  var attributedString = Date.now.formatted(
    .dateTime
    .hour()
    .minute()
    .weekday()
    .attributed
  )
  let weekContainer = AttributedContainer().dateField(.weekday)
  let colorContainer = AttributedContainer().foregroundColor(.red)
  attributedString.replaceAttributes(weekContainer, with: colorContainer)
  return attributedString
}

Text(dateString)

Output (Fri in red color):

Fri 12:21 PM

File Format Supported

AttributedString currently only supports Markdown files.

Conversion between AttributedString and NSAttributedString

// AttributedString -> NSAttributedString
let nsString = NSMutableAttributedString("good")
var attributedString = AttributedString(nsString)

// NSAttribuedString -> AttributedString
var attString = AttributedString("bye")
attString.uiKit.foregroundColor = .blue
let nsString1 = NSAttributedString(attString)

The ability of conversion can be leveraged to:

App Scene

App

New protocol in SwiftUI 2.0, creating an app via a struct that conforms to App protocol, and provide app content via body.

Scene

The container of View hierarchy, App is constructed by one or more Scene in the body of App instance.

Lifecycle Events

AppDelegate:

@main
struct NewAllApp: App {
  @UIApplicationDelegateAdapter(AppDelegate.self) var appDelegate
  var body: some Scene {
    WindowGroup {
      Text("Hello, world!")
    }
  }
}

class AppDelegate: NSObject, UIApplicationDelegate {
  func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
      return true
    }
}

SceneDelegate:

@main
struct NewAllApp: App {
  @Environment(\.scenePhase) var phase
  var body: some Scene {
    WindowGroup {
      ContentView()
    }
    .onChange(of: phase) { phase in
      switch phase {
      case .active: break
      case .inactive: break
      case .background: break
      @unknown default: break
      }
    }
  }
}

@AppStorage

A property wrapper around UserDefault, can be used at any View level.

struct RootView: View {
  @AppStorage("count") var count = 0
  var body: some View {
    List {
      Button("+1") {
        count += 1
      }
    }
  }
}

@SceneStorage

Lifecycle within Scene. In PadOS, quitting one of split scene andreopen the app, data is persisted; Quitting a single scene will lose the data.

Display Map in SwiftUI

import MapKit
import SwiftUI

struct MapView: View {
  @State private var region: MKCoordinateRegion = .init(
    center: CLLocationCoordinate2D(latitude: 38.92083, longitutde: 121.63917),
    span: MKCoordinteSpan(latitudeDelta: 0.5, longitudeDelta: 0.5)
  )

  @State private var trackMode = MapUserTrackingMode.follow

  let dots: [MapDot] = [
    MapDot(title: "P1", coordinate: CLLocationCoordinate2D, color: .red)
  ]

  @StateObject var store = Store()

  var body: some View {
    ZStack(alignment: .bottom) {
      Map(
        coordinateRegion: $region,
        interactionModes: .all, // pan, zoom, all
        showsUserLocation: true,
        userTrackingMode: $trackMode,
        annotationItems: dots
      ) { item in
        MapAnnotation(coordinate: item.coordinate) {
          // …
        }
      }
      .edgeIgnoringSafeArea(.all)
    }
  }
}

LazyVStack adn LazyHStack, List, Form, VStack and ScrollView

LazyVStack, LazyHStack: Content is only rendered when they are visible.

List and Form are based on UITableView:

Two Ways to Create List

List(0..<100) { i in
  Text("id: \(i)")
}

List {
  ForEach(0..<100) { i in
    Text("id: \(id)")
  }
}

Label

Label is introduced in SwiftUI 2.0, for creating labels that have image and/or text.

Label("Hello, world!", systemImage: "person.badge.plus")

struct LabelTest: View {
  var body: some View {
    List(LabelItem.labels(), id: \.id) { label in
      Label(label.title, systemImage: label.image)
        .foregroundColor(.blue)
        .labelStyle(MyLabelStyle(color: label.color))
    }
  }
}

struct MyLabelStyle: LabelStyle {
  let color: Color
  func makeBody(configruation: Self.Configuration) -> some View {
    HStack {
      configuration.icon
        .font(.titlte)
        .foregroundColor(color)
      configuration.title
        .foregroundColor(.blue)
      Spacer()
    }.padding(.all, 10)
    .background(
      RoundedRectangle(cornerRadius: 10)
        .fill(Color.yellow)
    )
  }
}

SwiftUI Grid

SwiftUI 2.0 provides LazyVGrid and LazyHGrid

struct GridTest: View {
  let columns = [
    GridItem(.adaptive(minimum: 50))
    // adaptive: multiple items in the space of a single flexible item
    // fixed: a single item in a fixed size
    // flexible: a single flexible item
    // can be mixed
  ]

  var body: some View {
    ScrollView {
      LazyVGrid(
        columns: columns,
        alignment: .center,
        spacing: 20,
        pinnedViews: [.sectionHeaders]
      ) {
        Section(header: Text("Header")) {
          ForEach(0...100, id: \.self) { id in
          Text(String(id))
          .foreground(.black)
          }
        }
      }
    }
  }
}

Open URL scheme

Link: similar to NavigationLink, open URL scheme’s corresponding app:

Link("openURL", destination: safariURL)

openURL: Apple has provided a number of ways to invoke system operation via injected Environment:

@Environment(\.openURL) var openURL // conforms to callAsFunction
openURL(url)
struct URLTest: View {
  @Environment(\.openURL) var openURL
  let mailURL = URL(string: "mailto: foo@example.com")!
  let safariURL = URL(string: "https://www.google.com")!
  let phoneURL = URL(string: "tel:12345")

  var body: some View {
    List {
      Link("Open website", destination: safariURL)
      Button("Send Email") {
        openURL(mailURL) { result in print(result) }
      }
      Link(destination: phoneURL) {
        Label("Call", systemImage: "phone.circle")
      }
    }
  }
}

openURL as a view modifier, can be used on any View to handle universal link.

struct ContentView: View {
  @State var tabSelection: TabSelection = .news
  @State var show = false

  var body: some View {
    TabView(selection: $tabSelection) {
      Text("News").tabItem {
        Image(systemName: "newspaper")
      }.tag(TabSelection.news)
      Text("Music").tabItem {
        Image(systemName: "music.quarternote.3")
      }.tag(TabSelection.music)
      Text("Settings").tabItem {
        Image(systemName: "dial.max")
      }.tag(TabSelection.settings)
    }
    .sheet(isPresented: $show) {
      Text("URL error")
    }
    .onOpenURL { url in
      switch url.host {
        case "news": tabSelection = .news
        case "music": tabSelection = .music
        case "settings": tabSelection = .settings
        default: show = true
      }
    }
  }
}

Toolbar

Toolbar has all the functionalities of navigationBarItems.

NavigationView {
  Text("Toolbar Demo")
  .toolbar {
    ToolbarItem(placement: .automatic) {
      HStack(spacing: 20) {
        Button(action: {print("???")} ) {
          Image(systemName: "waveform.path.badge.plus")
        }
      }
    }
  }
}

Progress View

Custom Style

ProgressView("Job Completion", value: 25, total: 100)
  .progressViewStyle(MyProgressStyle())

struct MyProgressViewStyle: ProgressViewStyle {
  let foregroundColor: Color
  let backgroundColor: Color

  func makeBody(configuration: Configuration) -> some View {
    GeometryReader { proxy in
      ZStack(alignment: .topLeading) {
        backgroundColor
        Rectangle()
          .fill(foregroundColor)
          .frame(width: proxy.size.width * CGFloat(configuration.fractionCompleted ?? 0))
      }.clipShape(RoundedRectangle(cornerRadius: 10))
      .overlay(configuration.label.foreground(.white))
    }
  }
}

ScrollViewReader

SwiftUI 2.0 supports scroll view offset positioning via id.

Right to Left

struct ScrollReaderTest: View {
  var body: some View {
    ScrollView(.horizontal) {
      ScrollViewReader { proxy in
        Rectangle()
          .fill(LinearGradient(
            gradient: Gradient(colors: [.black, .red]),
            startingPoint: .leading,
            endPoint: .trailing
          ))
          .frame(width: 1000, height: 300, alignment: .center)
          .id("rec")
          .onAppear {
            proxy.scrollTo("rec", anchor: .trailing)
          }
      }
    }
  }
}

Scroll to Item Position

@State private var position = 0

var body: some View {
  ScrollView {
    ScrollViewReader { proxy in
      LazyVStack {
        ForEach(list, id: \.id) { item in
          Text(item.title).id(item.id)
        }
      }
      .onChange(of: position) { position in
        switch position {
          case 1: withAnimation(Animation.easeInOut) {
            proxy.scrollTo(id, anchor: .top)
          }
        }
      }
    }
  }
}

Sheet

If your code shows different sheets in different places, you’ll need different switches, and if your view hierarchy is deep, sheet son’t show from view deep in the hierarchy.

@State var showView1 = false
@State var showView2 = false

List {
  Button("View 1") {
    showView1.toggle()
  }
  .sheet(isPresented: $showView1) {
    Text("View 1")
  }
  Button("View 2") {
    showView2.toggle()
  }
  .sheet(isPresented: $showView2) {
    Text("View 2")
  }
}

Use Binding Items

sheet(item: Binding<Identifiable?>, content: (Identifiable) -> View)

struct SheetUsingAnyView: View {
  @State private var sheetView: AnyView?
  var body: some View {
    NavigationView {
      List {
        Button("View 1") {
          sheetView = AnyView(View1())
        }
        Button("View 2") {
          sheetView = AnyView(View2())
        }
      }
      .sheet(item: $sheetView) { view in
        view
      }
    }
  }
}

extension AnyView: Identifiable {
  public var id: UUID { UUID() }
}

Reducer

@State private var sheetAction: SheetAction?

var body: some View {
  Button("show") {
    sheetAction = .view1 // .view2, …
  }
  .sheet(item: $sheetAction) { action in
    makeViewForAction(action)
  }
}

private func makeViewForAction(_ action: SheetAction) -> some View {
  switch action {
    case .view1: return View1()
    case .view2: return View2()
  }
}

Reduce-like Store using async-await

@MainActor
final class Store: ObservalbeObject {
  @Published private(set) var state = AppState()
  private let environment = Environment()

  @discardableResult
  func dispatch(_ action: AppAction) -> Task<Void, Never>? {
    Task {
      if let task = reducer(state: &state, action: action, environment: environment) {
        do {
          let action = try await task.value
          dispatch(action)
        } catch { print(error) }
      }
    }
  }
}

extension Store {
  func reducer(state: inout AppState, action: AppAction, environment: Environment) -> Task<AppAction, Error>? {
    switch action {
      case .empty: break
      case .setAge(let age):
        state.age = age
        return Task {
          await environment.setAge(age)
        }
      case .setName(let name):
        state.name = name
        return Task {
          await environment.setName(name)
        }
    }
    return nil
  }
}

final class Environment {
  func setAge(_ age: Int) async -> AppAction {
    .empty
  }
  func setName(_ name: String) async -> AppAction {
    await Task.sleep(2 * 1000000000)
    return .setAge(age: Int.random(in: 1...100))
  }
}

@main
struct NewReduxTestApp: App {
  @StateObject var store = Store()
  var body: some Scene {
    WindowGroup {
      ContentView()
        .environmentObject(store)
    }
  }
}

Alternatively:

@main
@MainActor
struct NewReduxApp: App {
  var store = Store()
  var body: some Scene {
    WindowGroup {
      ContentView()
      .environmentObject(store)
    }
  }
}

Safe Area in SwiftUI

Safe area is the area of screen realestate that doesn’t overlap with navigation bar, status bar, tool bar or any control that’s provided by view controller.

In UIKit, we use either safeAreaInsets or safeAreaLayoutGuide to make sure our view content is visible, SwiftUI simplifies this by making sure view content is visible unless we explictly ignore safe area.

For root view, safeAreaInsets includes the space occupied by status bar, navigation bar, toolar, tab bar, etc. for the views above root view, safe area insets are only the parts that’s covered; For views that are 100% visible, safe area insets are 0.

GeometryReader

GeometryReader provides safeAreaInsets:

struct SafeAreaInsetsKey: PreferenceKey {
  static var defaultValue = EdgeInsets()
  static func reduce(value: inout EdgeInsets, nextValue: () -> EdgeInsets) {
    value = nextValue()
  }
}

extension View {
  func getSafeAreaInsets(_ safeInsets: Binding<EdgeInsets>) -> some View {
    background(
      GeometryReader { proxy in
        Color.clear
        .preference(key: SafeAreaInsetsKey.self, value: proxy.safeAreaInsets)
      }
      .onPreferenceChange(SafeAreaInsetsKey.self) { value in
        safeInsets.wrappedValue = value
      }
    )
  }
}

struct GetSafeArea: View {
  @State var safeAreaInsets: EdgeInsets = .init()
  var body: some View {
    NavigationView {
      VStack { Color.blue }
    }
    .getSafeAreaInsets($safeAreaInsets)
  }
}

Via Key Window

extension UIApplication {
  var keyWindow: UIWindow? {
    connectedScenes
      .compactMap { $0 as? UIWindowScene }
      .flatMap { $0.window }
      .first { $0.isKeyWindow }
  }
}

private struct SafeAreaInsetsKey: EnvironmentKey {
  static var defaultValue: EdgeInsets {
    UIApplication.shared.keyWindow?.safeAreaInsets.swiftUIInsets ?? .zero
  }
}

extension EnvironmentKey {
  var safeAreaInsets: EdgeInsets {
    self[SafeAreaInsetKey.self]
  }
}

private extension UIEdgeInsets {
  var swiftUIInsets: EdgeInsets {
    EdgeInsets(top: top, leading: left, bottom: bottom, trailing: right)
  }
}
// @Environment(\.safeAreaInsets) private var safeAreaInsets

Ignore Safe Area

.ignoresSafeArea() extends the view to non-safe area.

@inline public func ignoresSafeArea(_ region: SafeAreaRegions = .all, edge: Edge.Set = .all) -> some View

Above doesn’t work when keyboard is visible because we haven’t set regions correctly.

SafeAreaRegions

Since iOS 14, SwiftUI takes into account software keyboard when calculating safe area. But not all views need to consider keyboard area when ignoring safe area.

Imagine a text field on a background, when text field is editing, keyboard slides up. We want it to push text field up (not ignore safe area) but leave background unchanged (ignores safe area):

struct IgnoreSafeAreaTest: View {
  var body: some View {
    ZStack {
      Rectangle().fill(.linearGradient(.init(colors: [.red, .blue, .orange], startingPoint: .topLeading, endPoint: .bottomTrailing)))
        .ignoresSafeArea(.all)
      VStack {
        TextField("name", text: constant(""))
          .textFieldStyle(.roundedCorner)
          .padding()
      }
    }
  }
}

In SwiftUI, ocmponents that are based on UIScrollView, by default expand to full screen but they make sure their content are visible by adding blank space on the edge. When they are embedded inside TabView, TabView will adjust its internal safe area. Since iOS 15, there’s the safeAreaInsets modifier that lets you adjust safe are and make sure our custom views are visible.

struct AddSafeAreaDemo: View {
  ZStack {
    Color.yellow.border(.red, width: 10)
  }
  .safeAreaInsets(edge: .bottom, alignment: .center, spacing: 0) {
    Rectangle().fill(blue)
    .frame(100)
  }
  .ignoreSafeArea()
}

When you have text fields that need to adjust for keyboard but you also get some custom bottom view that need to stay, listen to keyboard willShow and willHide notifications, apply safeAreaInsets only when keyboard is not present.

Alignment

Alignment is a layout behaviour between a number of views, in SwiftUI, multiple views are aligned in a view container based on alignment guide.

Alignment Guide

Alignment guide doesn’t only align to points, but also align to lines.

In SwiftUI, HorizontalAlignment and VerticalAlignment are used to identify horizontal and vertical reference lines, and they can be used together to dientify reference point.

The essense of HorizontalAlignment and VerticalAlignment is a function that returns a CGFloat, which is the offset value with regard to specific axis.

Alignment guide supports multiple layout directions:

VStack(alignment: .leading) {
  Text("Hi")
  Text("Bye")
}
.environment(\.layoutDirection, .rightToLeft)

Custom Alignment Guide

struct: OneThirdWidthID: AlignmentID {
  static func defaultValue(in context: ViewDimentions) -> CGFloat {
    context.width / 3
  }
}

extension HorizontalAlignment {
  static let oneThird = HorizontalAlignment(OneThirdWidthID.self)
}

extension Alignment {
  static let customAlignment = Alignment(horizontal: .oneThird, vertical: .top)
}

We can also modify the value of alignment via alignmentGuide modifier:

struct AlignmentGuideDemo: View {
  var body: some View {
    VStack(alignment: .leading) {
      rectangle
        .alignmentGuide(.leading, computeValue: { viewDimentions in
          let defaultLeading = viewDimensions[.leading]
          let newLeading = defaultLeading + 30
          return newLeading
        })
      rectangle
    }
    .border(.pink)
  }

  var rectangle: some View {
    Rectangle()
      .fill(.blue.gradient)
      .frame(width: 100, height: 100)
  }
}

The result is first rectangle will look 30 points left w.r.t leading line because we chagned its “leading” by adding 30 points.

Alignment for VStack, HStack and ZStack etc

The following code modifies leading of ZStack by 10 points:

ZStack(alignment: .bottomLeading) {
  Rectangle()
    .fill(.orange.gradient)
    .frame(width: 100, height: 300)
  Rectangle()
    .fill(.cyan.gradient).opacity(0.7)
    .frame(width: 300, height: 100)
}
.alignmentGuide(.leading) {
  $0[.leading] + 10
}

The alignment for stack views are effective for the subviews, so instead of aligning subviews against ZStack’s bottom leading, that has 10 points offset, the two rectangles will appear bottom leading aligned with each other as well as ZStack without offset.

Dimension

SwiftUI calcualtes the position and size of each view in the view tree.

struct ContentView: View {
    var body: some View {
        ZStack {
            Text("Hello world")
        }
    }
}
// ContentView
//     |
//     |———————— ZStack
//                 |
//                 |—————————— Text
  1. SwiftUI provides a recommended dimension to ZStack, S1, and asks ZStack for required dimension.
  2. ZStack then provides size S1 to Text, and asks Text for required dimension.
  3. Text returns a smaller size, S2, because S1 is bigger than S2, the required size for Text is a size that the text doesn’t have to go multiple lines or truncation.
  4. ZStack returns S2 because there’s only Text inside and S2 is the required size from Text.
  5. SwiftUI put ZStack at location (S1 - S2) / 2, with a dimension of S2. ie. center in the container view with required size from Text.

Stage 1: Negotiation

In this stage, parent views provide recommended views to children, children returns required dimension. In Layout protocol, the required dimension maps to sizeThatFits.

Stage 2: Placement

In this stage, parent views sets position and dimension for children using the provided dimension. In Layout protocol, this maps to placeSubviews.

The two stages can be repeated a number of times in complex view hierarchy.

Container and View

In SwiftUI, a component is handled by ViewBuilder only if it conforms to View, so any layout container will eventually be encapsulated in the form of View.

public struct VStack<Content>: SwiftUI.View where Content: View {
    internal var _tree: _VariadicView.Tree<_VStackLayout, Content>
    public init(alignment: SwiftUI.HorizontalAlignment = .center, spacing: CoreFoundation.CGFloat? = nil, @SwiftUI.ViewBuilder content: () -> Content) {
        _tree = .init(
            root: _VStackLayout(alignment: alignment, spacing: spacing), content: content()
        )
    }
    public typealias Body = Swift.Never
}

A number of layout containers exist in the form of view modifier:

public extension SwiftUI.View {
    func frame(width: CoreFoundation.CGFloat? = nil, height: CoreFoundation.CGFloat? = nil, alignment: SwiftUI.Alignment = .center) -> some SwiftUI.View {
        return modifier(
            _FrameLayout(width: width, height: height, alignment: alignment))
    }
}

public struct _FrameLayout {
    let width: CoreFoundation.CGFloat?
    let height: CoreFoundation.CGFloat?
    init(width: CoreFoundation.CGFloat?, height: CoreFoundation.CGFloat?, alignment: SwiftUI.Alignment)
    public typealias Body = Swift.Never
}

So the following code:

Text("Hi")
    .frame(width:100, height:100)

is equivalent to:

_FrameLayout(width:100, height:100, alignment:.center) {
    Text("Hi")
}

There is a type of view that appears as parent view in view tree but doesn’t involve in layout alignment, for example, Group, ForEach.