SwiftUI Study Notes
TOC
- SwiftUI Study Notes
- TOC
- State Driven UI
- Property Wrapper
- Binding
- Single Source of Truth
- FocusedBinding
- AttributedString
- App Scene
- Display Map in SwiftUI
- LazyVStack adn LazyHStack, List, Form, VStack and ScrollView
- Label
- SwiftUI Grid
- Open URL scheme
- Handle universal link via onOpenURL
- Toolbar
- Progress View
- ScrollViewReader
- Sheet
- Reduce-like Store using async-await
- Safe Area in SwiftUI
- Alignment
- Dimension
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:
- Value type:
- Read only: reguar property
- Read/Write:
- Passed in from outside:
@Binding
- Local view state:
@State
- Passed in from outside:
- Object:
- Received as parameter:
@ObservedObject
- Created by the view itself:
@StateObject
- Through the environment:
@EnvironmentObject
- Received as parameter:
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 │──┘
└────────────┘
- app as a state machine, states determine UI
- all states are saved in the
Store
object View
doesn’t directly modify state, instead, view sends action which modifies state inStore
- a
Reducer
that knows existing state, given action, produces a new state - new states replace old states in
Store
, which triggers view update
Implementation:
Store
conforms toObservedObject
- States are saved in
Store
, defined as@Published
State
has relation withView
via@ObservedObject
Store
updatesView
via the publisher ofobjectWillChange
whenState
updatesView
sendsAction
toReducer<State, Action>
which produces new state- (optional) Due to the bi-directional nature of SwiftUI binding, data communication is bi-directional
- (optional) In certain
View
, other property wrappers like@FetchRequest
can be used for opptimisation
Problems
As Store
grows and the number of Views increases, the reponsiveness of the app may decrease. Solution:
- the timing of the injection ob
ObservedObject
- complexity of
View
- 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
…
MainView
doesn’t use date to display or checking its value but still updated- Even if date isn’t used at all, with view model declared the view is still updated
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.
- lower coupling
- higher reuseability
- ViewBuildeer separates code into individual views
- optimisation per 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
- re-wrap @State so updating its value can also trigger a side effect
- create our own
@EnvironmentKey
orPreferenceKey
and inject into View branch
@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:
- state remains unchanged
- each state property can notify View, instead of
@Published
- each
View
can only listen to the state variables it’s interested in, so no update if irrelevant state properties change - state properties should still support
Binding
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:
- foundation
- swiftUI
- uiKit
- appKit
- accessibility
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.
- Create custom
AttributedStringKey
- Create custom
AttributeScope
- 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:
- Parse HTML using
NSAttributedString
and convert toAttributedString
- Create type safe
AttributedString
and convert toNSAttributedString
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
.
- app entry defined by
@main
, each project can have one entry - it manages full app lifecycle
- under
App
, any constant, variable defined have the same lifecycle as the app
Scene
The container of View
hierarchy, App
is constructed by one or more Scene
in the body of App
instance.
- lifecycle manag4ed by system
- presentation behavior differs based on platform
- SwiftUI 2.0 provides a number of scene presets, e.g.
WindowGroup
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
:
List
with data source is similar toForm
withForEach
Form
doesn’t support selection bindingList
has styles andForm
is only one styleList
embedded inForm
is expanded and flattenedForm
orList
embedded inList
, orForm
inForm
, are displayed as a single row
Two Ways to Create List
List(0..<100) { i in
Text("id: \(i)")
}
List {
ForEach(0..<100) { i in
Text("id: \(id)")
}
}
- when using
ForEach
, each view is initialized whenList
is constructed, and these views are not deallocated even if they are invisible.
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")
}
}
}
}
Handle universal link via onOpenURL
openURL
as a view modifier, can be used on any View
to handle universal link.
- only works when lifecycle is managed by Swift App
- multiple Views can have openURL
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
- classic:
ProgressView()
- linear:
ProgressView("Loading…", value: 40, total: 100)
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
- make sure
State
can only be modified on main thread using@MainActor
- dispatch creates disposable
Task
to manage lifecycle of side effect - side effect returns
Task<AppAction, Error>
@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
- expands to bottom:
.ignoresSafeArea(edges: .bottom)
- expands bottom & trailing:
.ignoresSafeArea(edges: [.bottom, .trailing])
- expands horizontally:
.ignoresSafeArea(edges: .horizontal)
Above doesn’t work when keyboard is visible because we haven’t set regions correctly.
SafeAreaRegions
- container: the safe are defined by the device and containers within the user interface, including elements such as top and bottom bars.
- keyboard: the safe are matching the current content of any software keyboard displayed over the view content
- all: all safe area regions
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()
}
safeAreaInsets
can be used on top of another
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.
HorizontalAlignment
: leading, center, trailingVerticalAlignment
: top, center, bottomAlignment.topLeading
: HorizontalAlignment.leading, VerticalAlignment.top
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
- SwiftUI provides a recommended dimension to
ZStack
, S1, and asksZStack
for required dimension. ZStack
then provides size S1 toText
, and asksText
for required dimension.Text
returns a smaller size, S2, because S1 is bigger than S2, the required size forText
is a size that the text doesn’t have to go multiple lines or truncation.ZStack
returns S2 because there’s onlyText
inside and S2 is the required size fromText
.- SwiftUI put
ZStack
at location (S1 - S2) / 2, with a dimension of S2. ie. center in the container view with required size fromText
.
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
.