My top swiftUI tips and tricks
40: Type erase with @ViewBuilder
Using group and AnyView are other ways to achive the same thing. Check with copilot for pros and cons for each solution.
func getContent(flag: Bool) -> some View {
@ViewBuilder var content: some View {
if flag {
Text("Text")
} else {
Button("Button")
}
}
return content.background(Color.green)
}
39: SizeObserver:
class SizeObserver: ObservableObject {
@Published var previousSize: CGSize = .zero
@Published var currentSize: CGSize = .zero
func updateSize(_ newSize: CGSize) {
if newSize != currentSize {
previousSize = currentSize
currentSize = newSize
print("Size changed from \(previousSize) to \(currentSize)")
}
}
}
struct ContentView: View {
@StateObject private var sizeObserver = SizeObserver()
var body: some View {
GeometryReader { geometry in
Color.clear
.onAppear {
sizeObserver.updateSize(geometry.size)
}
.onChange(of: geometry.size) { newSize in
sizeObserver.updateSize(newSize)
}
}
}
}
38. SizeTracker:
import SwiftUI
struct SizeTracker: ViewModifier {
@State private var previousSize: CGSize = .zero
@State private var currentSize: CGSize = .zero
var onSizeChange: (CGSize, CGSize) -> Void
func body(content: Content) -> some View {
content
.background(
GeometryReader { geometry in
Color.clear
.onAppear {
currentSize = geometry.size
}
.onChange(of: geometry.size) { oldSize, newSize in
previousSize = currentSize
currentSize = newSize
onSizeChange(previousSize, currentSize)
}
}
)
}
}
extension View {
func trackSize(onChange: @escaping (CGSize, CGSize) -> Void) -> some View {
self.modifier(SizeTracker(onSizeChange: onChange))
}
}
// Usage
struct ContentView: View {
var body: some View {
Text("Hello, World!")
.frame(width: 200, height: 100)
.trackSize { oldSize, newSize in
if oldSize != newSize {
print("Size changed from \(oldSize) to \(newSize)")
}
}
}
}
37. Passing read-and-write-data downstream with environment object
- Read and write environment objects
- Changes to the variable triggers view update to parents and children
- Avoids “parameter drilling” (passing variables to every level of the view hierarchy)
- @EnvironmentObjectis more convenient than ObservedObject but requires careful management to avoid runtime crashes.
- Changing child views to new views, requires reapplying the environment variable etc
class SharedData: ObservableObject {
@Published var value = "Initial value" // state variable
}
struct ParentView: View {
@StateObject private var sharedData = SharedData() // onChange will update on var mutation from child
var body: some View {
VStack {
Text("Parent: \(sharedData.value)")
ChildView().environmentObject(sharedData)
}
}
}
struct ChildView: View {
@EnvironmentObject var sharedData: SharedData
var body: some View {
VStack {
Text("Child: \(sharedData.value)")
Button("Modify Data") {
sharedData.value = "Modified by child" // modify origin object
}
}
}
}
36. Passing read-only-data downstream with environment variables
- Environment values are read-only for child views but can be modified in the origin parent view.
- Changes to Environment values trigger view updates.
- Avoids parameter drilling (passing variables to every level of the view hierarchy)
struct ParentView: View {
@Environment(\.horizontalSizeClass) var sizeClass
var body: some View {
GrandChildView()
.environment(\.horizontalSizeClass, sizeClass) // overrides downstream
}
}
struct ChildView: View {
var body: some View {
GrandChildView()
}
}
struct GrandChildView: View {
@Environment(\.horizontalSizeClass) var sizeClass
var body: some View {
print(sizeClass) // .regular (this is received from parent-view)
}
}
// You can also create your own environment keys.
private struct MyEnvironmentKey: EnvironmentKey {
static let defaultValue: String = "Default Value"
}
extension EnvironmentValues {
// @Environment(\.myEnvironmentKey) var sizeClass
// .environment(\.horizontalSizeClass, "New Value")
var myEnvironmentKey: String {
get { self[MyEnvironmentKey.self] }
set { self[MyEnvironmentKey.self] = newValue }
}
}
35. implicit vs explicit return of EmptyView in @ViewBuilder
- This snippet uses a guard statement to check if string can be assigned to str. If string is nil, it returns an EmptyView(), effectively rendering nothing. If string is not nil, it proceeds to return a Text view displaying the value of str.
@ViewBuilder func test() { guard let str = string else { return EmptyView() } return Text(str) }
- This snippet uses an if let statement to check if string can be assigned to str. If string is not nil, it returns a Text view displaying the value of str. However, unlike the first snippet, if string is nil, it does nothing and implicitly returns an EmptyView() due to the behavior of @ViewBuilder.
@ViewBuilder func test() { if let str = string { return Text(str) } }
Key Differences
Explicit vs Implicit Return: The first snippet explicitly returns an EmptyView() when the condition fails, making the control flow clearer. The second snippet relies on the implicit return of EmptyView() by @ViewBuilder when no other views are returned, which might be less obvious to someone reading the code.
Readability and Intent: The explicit return of EmptyView() in the first snippet makes it clear that the function intentionally renders nothing under certain conditions. The second snippet assumes the reader understands the behavior of @ViewBuilder with conditional statements.
Conclusion
While both snippets aim to conditionally render a Text view based on whether string is nil, they differ in their approach to handling the case where string is nil. The first snippet explicitly handles this case by returning an EmptyView(), whereas the second snippet relies on the implicit behavior of @ViewBuilder. Depending on coding style preferences and readability concerns, one may choose either approach.
34. nonmutating
Here’s a simple example of using nonmutating in a property setter within a Swift property wrapper. This example demonstrates how to use nonmutating to allow the state variable to be modified without changing the instance of the property wrapper itself.
The nonmutating keyword in the setter allows the wrappedValue to be updated without changing the instance of ExamplePropertyWrapper. This is useful when you want to modify the internal state while keeping the property wrapper instance itself unchanged.
@propertyWrapper
public struct ExamplePropertyWrapper {
private var value: Int // The stored value
public var wrappedValue: Int { // The property wrapper's wrapped value
get { value } // Getter for the value
nonmutating set { // Setter for the wrapped value, marked as nonmutating
value = newValue // Update the stored value
}
}
public init(wrappedValue: Int) {
self.value = wrappedValue // Initialize the stored value
}
}
33. Whats the difference between initialValue and wrappedValue for a State variable?
There is no difference, they are the same. Apple shipped both and can’t remove one because of ABI compatibility.
32. Easy way to double check sizes
- Set the simulator to 1x size. This can be ipad, mac or iphone.
- Hit cmd + shift + 4 Now you can drag and measure with the printscreen ruler
31. Using Accessibility inspector with swiftui
In recent xcode releases this has stopped working like it used to. To get it working. Start a UITest session. And add a sleep(sec: 120) call somewhere. the inspector seem to work only when the app is live in the UITest session. Not in regular simulator mode. (works for iPhone, for iPad it gets things a bit wrong, misplacements etc)
30. Using NSAttributedText in swiftui
If you have existing NSAttributedString instances that you need to use in SwiftUI, you can convert them to AttributedString using the initializer that accepts an NSAttributedString:
let nsAttributedString = NSAttributedString(string: "Your text", attributes: [.foregroundColor: UIColor.red])
let attributedString = AttributedString(nsAttributedString)
29. Call accessibilityIdentifier indirectly
Because everything is a chain. One misplaces accessibiliytIdentifer can cause the app to crash in strange places. With no way of figuring out which id causes the issues.
A solution is to call it indirecttly so it can be turned on and off and print a log of where it causes the crash. Here is an example of such a method:
⚠️️ And sometimes XCode caches accessibilityIdentifiers somewhere it should not. So only a xcode rest will fix it. Erasing all data in the derivedData folder and simulator can also work
public func accessIdentifier(_ id: String) -> some View {
Swift.print("accessIdentifier - id: \(id)")
return self.accessibilityIdentifier(id) // return self to debug where things crash
}
28. Injecting dimiss into child view
Inspired by: https://www.swiftbysundell.com/articles/dismissing-swiftui-modal-and-detail-views/ and https://stackoverflow.com/a/74449402/5389500
This code lets you call the parent dismiss call from child. Sometimes useful for NavStacks inside popover sheets etc. Injecting the dimiss directly might cause crashes / infinite loops. Even when passing dismiss as DismissAction, so we call it indirectly with a function callAsFunction
.
It might also be possible to use presentation mode: https://nilcoalescing.com/blog/UsingTheDismissActionFromTheSwiftUIEnvironment/
struct ParentView: View {
@Environment(\.dismiss) private var dismiss
...
ChildView(dismiss: dismiss.callAsFunction)
}
struct childView: View {
var dismiss: (() -> Void)?
...
dimiss?()
}
27. Dealing with Invalid frame dimension (negative or non-finite)
var someVal: CGfloat = otherVal > 0 ? otherVal : 0
26. UITests in swiftUI
When setting accessivilityIdentifier to swiftui containers. Prepend with
.accessibilityElement(children: .contain) // this is key for setting access-id to the container and not the last child etc
.accessibilityIdentifier("some-id")
25. AnyView and @ViewBuilder
Using AnyView to embed different views in a sheet can create strange errors in Simulator. Such as “unknown context” at AnyViewStorage error: llmd.
Instead limit the usage of @ViewBuilder if you don’t have to.
24. ForEachIndex
Iterate over view elements (Remember to return the view)
ForEachIndex([1,2,3]) { i in Text("\(i)") }
struct ForEachIndex<Data, Content>: View where Data: RandomAccessCollection, Content: View {
var data: Data
var content: (Int) -> Content
init(_ data: Data, _ content: @escaping (Int) -> Content) {
self.data = data
self.content = content
}
var body: some View {
ForEach(Array(data.enumerated()), id: \.offset) { index, _ in
content(index)
}
}
}
23. Grouping styles
Sometimes you dont need to make a ViewModifier
/**
* TextStyle
*/
extension Text {
/**
* Style for brand text
* ## Examples:
* Text("\(String(text.prefix(2)).capitalized)")
* .brandIconTextStyle
*/
var brandIconTextStyle: some View {
self
.multilineTextAlignment(.center)
.foregroundColor(.whiteOrBlack.opacity(0.6))
.font(.system(size: 16))
.fontWeight(.bold)
}
}
22. No need to mark preview structs with debug:
To be extra clear, you DO NOT need to wrap your preview providers in #if DEBUG conditionals. They are removed from your production build. Ref: https://stackoverflow.com/a/60463426
#if DEBUG // this is not needed
#Preview {
MyView()
.previewDevice("iPhone 12 Pro")
.environment(\.sizeCategory, .large)
}
#endif // this is not needed
21. ForEachElement
Avoids the need for hashable, index is used as id ForEachElement(["a","b","c"]) { text($0) }
struct ForEachElement<Data, Content>: View where Data: RandomAccessCollection, Content: View {
var data: Data
var content: (Data.Element) -> Content
var body: some View {
ForEach(Array(data.enumerated()), id: \.offset) { _, element in
content(element)
}
}
}
20. ForEachEnumerated
This method pairs each element in the collection with its index, allowing you to iterate over both the index and the element simultaneously.
struct EnumeratedForEach<Data, Content>: View where Data: RandomAccessCollection, Data.Element: Hashable, Content: View {
var data: Data
var content: (Int, Data.Element) -> Content
var body: some View {
ForEach(Array(data.enumerated()), id: \.element) { index, element in
content(index, element)
}
}
}
Data and Content. Data is the collection of items you want to iterate over, and Content is the type of view you want to generate for each item. The content closure takes the index and the element as parameters, allowing you to use both in your view.
struct ContentView: View {
let items = ["Apple", "Banana", "Cherry"]
var body: some View {
VStack {
EnumeratedForEach(data: items) { index, item in
Text("Item \(index + 1): \(item)")
}
}
}
}
19. Rebinding a local scoped variable
We can also do this via a binding extension that takes a closure.
// Loop over items in a ForEach here
let isSelected: Binding<Bool> = Binding( get: { $selectedIdx.wrappedValue == i }, set: { _ in $selectedIdx.wrappedValue = i })
// Create a button and inject binding to apply selected state here
18. Padding extension
Instead of this:
// Adds different padding values to all four sides
Text("Hello, SwiftUI!")
.padding(.top, 20)
.padding(.bottom, 10)
.padding(.leading, 5)
.padding(.trailing, 15)
Do this:
extension View {
func padding(_ edgeLengths: [Edge.Set: CGFloat]) -> some View {
var modifiedView = self
for (edge, length) in edgeLengths {
modifiedView = modifiedView.padding(edge, length)
}
return modifiedView
}
}
// Usage:
Text("Hello, SwiftUI!")
.padding([
.leading: 20,
.trailing: 40,
.top: 10,
.bottom: 30
])
17. Rebinding:
Here is an example where we change a state by rebinding it. (It is a great tool to use in many cases, when wiring up a complex UI)
@State private var columnVisibility: NavigationSplitViewVisibility = .all
@Binding var isColumnVisible: Bool = Binding( get: { columnVisibility == .all }, set: { columnVisibility = $0 ? .all : .doubleColumn })
$isColumnVisible.wrappedValue.toggle() // toggles the columnVisibility state
16. Simple user default wrapper:
class Prefs: ObservableObject {
@Published
var showCopyright: Bool = UserDefaults.standard.bool(forKey: "showCopyright") {
didSet {
UserDefaults.standard.set(self.showCopyright, forKey: "showCopyright")
}
}
}
@ObservedObject var prefs: Prefs
@EnvironmentObject var prefs: Prefs
15. Container views
“Container-views” are fundamental building blocks in SwiftUI. Here is how you make them:
struct ContainerView<Content: View>: View {
let content: Content
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
var body: some View {
content
}
}
// Starting from Swift 5.4, Swift can automatically create the 'init' function. This means we can use 'resultBuilder' for properties that are stored.
struct AmazingContainerView<Content: View>: View {
@ViewBuilder
let content: Content
var body: some View {
content
}
}
14. Type erasing with group:
In SwiftUI, you can use conditions to decide which components to show. But, because of the way SwiftUI works, you have to use a Group even if it’s not part of your design. This is because SwiftUI needs to know the exact type of what you’re returning, and Group helps with that.
@State private var toggle = false
var body: some View {
Group {
if toggle {
Text("It's true")
} else {
SFSymbol(.nosign)
}
}
}
13. ForEachWithIndex
public func ForEachWithIndex<Data: RandomAccessCollection, Content: View>( _ data: Data, @ViewBuilder content: @escaping (Data.Index, Data.Element) -> Content
) -> some View where Data.Element: Identifiable, Data.Element: Hashable {
ForEach(Array(zip(data.indices, data)), id: \.1) { index, element in
content(index, element)
}
}
// as a reusable View:
struct ForEachWithIndex<
Data: RandomAccessCollection,
Content: View
>: View where Data.Element: Identifiable, Data.Element: Hashable {
let data: Data
@ViewBuilder let content: (Data.Index, Data.Element) -> Content
var body: some View {
ForEachWithIndex(data: data) { index, element in
content(index, element)
}
}
}
// usage:
ForEachWithIndex(data: viewModel.books) { index, book in
VStack {
BookRow(book: book)
if index < viewModel.books.count - 1 {
Divider()
}
}
}
12. Apply closure onto view
public extension View {
@ViewBuilder
func applyIf<T: View>(_ condition: @autoclosure () -> Bool, apply: (Self) -> T) -> some View {
if condition() {
apply(self)
} else {
self
}
}
}
11. Erease view type
public extension View {
func erase() -> AnyView {
return AnyView(self)
}
}
10. Hide / visible:
public extension View {
@ViewBuilder
func hidden(_ hides: Bool) -> some View {
switch hides {
case true: self.hidden()
case false: self
}
}
}
9. if condition transform content:
Usage:
Rectangle()
if true == Optional(true) {
.fill(.green)
} else {
.fill(.blue)
}
Extension:
public extension View {
@ViewBuilder
func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
if condition {
transform(self)
} else {
self
}
}
}
8. Using swiftUI preview on a real device:
To preview SwiftUI views on a physical device, follow these steps:
- Connect your device to your development machine.
- Open a SwiftUI view in Xcode, which will display a preview of the view on the canvas.
- To preview the view on the connected device, toggle the “Preview On Device” button at the bottom of the canvas.
- It may take a few moments for the preview to show up on the device.
- Previewing on a device allows for a better sense of the look and feel of the user interface, shortens the feedback loop, and is faster than building and installing the application on the device.
- You can use preview data to test various configurations, which can save time.
- The preview on the device is a live preview, allowing interaction with the preview just like in the canvas.
By following these steps, you can easily preview your SwiftUI views on a physical device, which can help in faster and more effective user interface development[1].
More on how to use xcode preview here: https://sarunw.com/posts/xcode-previews
7. Adher to another views size
In SwiftUI, you can easily make two views the same size, either in height or width. You don’t need any complex tools for this, just use the frame() and fixedSize() functions.
On iOS, make the maximum height or width of each view you want to size as infinite. This will make it fill all the available space. Then, apply fixedSize() to the container they are in. This tells SwiftUI that these views should only take up the space they need.
So, SwiftUI will find the smallest space the views need, and let them take up that full amount. This way, the two views will always be the same size, no matter what they contain.
For example, you can make two text views the same height even if they have different text lengths. Just use the frame() and fixedSize() functions, and both text views will be the same size.
HStack {
Text("This is a brief text.")
.padding()
.frame(maxHeight: .infinity)
.background(.red)
Text("This is an extremely lengthy text with a significant amount of words that will certainly span multiple lines due to its length.")
.padding()
.frame(maxHeight: .infinity)
.background(.green)
}
.fixedSize(horizontal: false, vertical: true)
.frame(maxHeight: 200)
You can use the same method to make two views have the same width.
VStack {
Button("Sign in") { }
.foregroundStyle(.white)
.padding()
.frame(maxWidth: .infinity)
.background(.red)
.clipShape(Capsule())
Button("Forgot Password") { }
.foregroundStyle(.white)
.padding()
.frame(maxWidth: .infinity)
.background(.red)
.clipShape(Capsule())
}
.fixedSize(horizontal: true, vertical: false)
6. Debug dark and light mode simultaniously:
Dark mode and light mode of any UI component, vertically stacked, ready for preview ✨
/**
* Used to preview light-mode and dark-mode simultaniously
*/
struct PreviewContainer<Content: View>: View {
let content: Content
// init
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
// body
@ViewBuilder
var body: some View {
ZStack {
Rectangle()
.fill(Color.secondaryBackground)
.ignoresSafeArea(.all)
VStack(spacing: 0) {
content
.environment(\.colorScheme, .light)
content
.environment(\.colorScheme, .dark)
}
}
}
}
// Preview
#Preview {
PreviewContainer {
Button(action: {
Swift.print("on action")
}, label: {
Text( "Hello world")
Spacer()
})
.padding(16)
.background(Color(light: .white, dark: .black).opacity(1))
}
}
5. Inject colorscheme in preview
Instead of writing the UI component twice to test dark / light mode. We can pass scheme in a closure as shown bellow:
// Preview
#Preview {
let closure: (_ colorScheme: ColorScheme) -> some View = { colorScheme in
AccessoryRow(title: "Hello world", leadingImageName: "heart")
.padding(16)
.background(Color(light: .white, dark: .black).opacity(1))
.environment(\.colorScheme, colorScheme)
}
return ZStack {
Rectangle()
.fill(Color.secondaryBackground)
.ignoresSafeArea(.all)
VStack(spacing: 0) {
closure(.dark)
closure(.light)
}
}
}
4. Inject dismiss
- Useful if you embed NavStack in a fullScreen cover or similar
- This can also be achived with the binding that fullScreenCover passes
- Going back via binding or dismiss has slightly different animation behaviours
struct Main: View { @Environment(\.dismiss) var dismiss var dismissHock: Environment<DismissAction> { // A hack to get _dimiss in an extension _dismiss } ... // Some navigation code Content(dismiss: dismissHock) // <- this passes the Main dismiss so that we can dismiss at that level } struct Content: View { @Environment(\.dismiss) var dismissHock // use this to dismiss at main level @Environment(\.dismiss) var dismiss // use this to dismiss at content level }
3. Convenient padding extension
You can create an extension that sets separate vertical and horizontal padding values for a view. This can be done by defining a new function that accepts both vertical and horizontal padding values.
extension View {
func padding(vertical: CGFloat, horizontal: CGFloat) -> some View {
self.padding(.vertical, vertical)
.padding(.horizontal, horizontal)
}
}
// Now you can use this new function to set separate vertical and horizontal padding values:
Text("Hello, SwiftUI!")
.padding(vertical: 10, horizontal: 20)
2. IndexedForEach
Sometimes you need index in a for each
ForEach(Array(zip(data.indices, data)), id: \.0) { idx, item in
content(idx, item)
}
1. selectable List
Selectable list
SwiftuI’s built in List selection functionality doesn’t allow for much customization. Here is a way to do it manually:
- Create a state that is an optional int
- Create a list where id is the int.
List(Array(items.indecies), id: \.self)
- Create items:
{ i in let item = items[i] }
- Set the state on tap of the list item
- Change the selection state by doing:
let isSelected = selectedIndex == i
- Change selected state in the item:
let row = Row(isSelected: isSelected)
// code coming soon