普通视图

发现新文章,点击刷新页面。
昨天以前objc.io

Book Update: Thinking in SwiftUI

2023年9月26日 06:00

Today we're very happy to announce that our book Thinking in SwiftUI is updated for iOS 17, and available in both PDF as well as print.

After releasing the previous edition we have held a large number of workshops and gained a lot more experience with both SwiftUI itself as well as teaching the material. Because of this, we decided to rewrite the book from scratch.

The new book contains better explanations, and a lot more visuals, diagrams, and examples. During the writing of this new edition iOS 17 came out, which brought a lot of updates to SwiftUI. We decided to include all the relevant changes (clearly marked as such for those of us that have to support older platforms).

We explicitly set out with the goal of not trying to cover every possible API in SwiftUI. Instead, we focus on the fundamental principles behind SwiftUI. We start with explaining how SwiftUI views construct render trees. These trees are essential to understanding the other fundamental concepts: layout, state, animations and the environment.

Until now we have updated all of our books for free ever since we started objc.io (over ten years ago!). Since this update of Thinking in SwiftUI is not an incremental one, but a complete rewrite from the ground up, we've decided to release it as a new product.

We hope you'll enjoy the new version! You can find a PDF preview here.

Best from Berlin,
Florian and Chris

Book Update: Thinking in SwiftUI

2023年6月16日 06:00

During the last months, we have worked hard at updating our book Thinking in SwiftUI. Over the past years, we had the chance to conduct SwiftUI workshops for many companies. Based on this experience and the feedback we got, our approach to explaining SwiftUI has evolved a lot. Therefore, we chose to do a full rewrite of the book to be on par with our workshops.

The general structure still is very similar to the previous edition, but the content is brand new. We put a lot of emphasis on explaining view trees (which in fact will be the first chapter of the book) in this new edition, as well as how view trees are interpreted in terms of state, layout, animations, and more. The book includes a lot of new diagrams to visualize these concepts.

After WWDC, we were a bit torn about what to do. Should we put out a book without mentioning all the new things? This would be weird for people that can target iOS 17 / macOS 14. Yet rewriting the entire book for iOS 17 only is also not an option, as many of us still have to support older platforms. Instead, we chose to amend the current chapters with callout boxes, pointing out relevant changes. In addition, we'll have a separate iOS 17 chapter for the time being. For the final release of the book (once the new stuff is out of beta) we'll try to integrate the contents of that chapter into the rest of the book.

Due to this transition phase, we've decided to release the new edition as a beta version. We plan to put out the chapters one-by-one as we're integrating new iOS 17 topics. During this prerelease phase, we're also hosting a weekly Q&A live stream. The first live stream will be today (Friday June 16th) at 18:00 GMT+1 (9am PST). You can submit any questions you have for us over at this Github repository.

Until now we have updated all of our books for free ever since we started objc.io (over ten years ago!). Since this update of Thinking in SwiftUI is not an incremental one, but a complete rewrite from the ground up, we've decided to release it as a new product – the prelease version is available now.

Thinking in SwiftUI: Live Q&A

2023年6月12日 06:00

During the last months, we have worked hard at updating our book Thinking in SwiftUI. Now that WWDC23 is behind us, we don't just want to release a finished book — instead, we will update it for all the new APIs.

Due to this transition phase, we've decided to release the new edition as a beta version. We plan to put out the chapters one by one as we're integrating new iOS 17 topics. During this prerelease phase, we're also hosting a weekly Q&A live stream. The first live stream will be Friday the 16th at 18:00 CEST (9am PST). You can submit any questions you have for us over at this Github repository.

The first pre-release of our updated book will also be available this Friday. Keep an eye on this blog or subscribe to our mailing list to hear all about it.

Thanks!

Florian and Chris

Transitions in SwiftUI

2022年4月14日 06:00

During our SwiftUI Workshop we often notice that very few people seem to know about transitions, even though they're not very complicated and incredibly useful.

Transitions happen when a view is removed from the view tree, or added to the view tree. However, if you've done some SwiftUI, you will have noticed that there is no actual way to add views to the view tree — there is no addSubview(_:). Instead, you can only add and remove views through the combination of a state change and using an if statement (or switch or ForEach). In other words, views are somehow added and removed for us automatically, yet transitions fire only once. Before we dive into the details of this, let's consider a very simple transition:

	struct ContentView: View {
    @State var visible = false
    var body: some View {
        VStack {
            Toggle("Visible", isOn: $visible)
            if visible {
                Text("Hello, world!")
            }
        }
        .animation(.default, value: visible)
    }
}

When we run the above code we can see the text fade in and out. This is the default transition (.opacity). When the view gets inserted into the view tree, it fades in, and once it gets removed it fades out. Note that if the body executes again, the view doesn't fade in again unless the condition in the if statement changes.

To build up a mental model of what's happening, we can consider the SwiftUI view tree for the above view:

SwiftUI views are ephemeral: the body of ContentView gets executed and from it a render tree is created. This render tree is persistent across view updates, and it represents the actual views on screen. Once the render tree is updated, the value for body then goes away. Here's the render tree after the initial rendering:

Once we tap the switch, a state change happens and the body of ContentView executes again. The existing render tree is then updated. In this case, SwiftUI noticed that the if condition changed from false to true, and it will insert our Text view into the render tree:

The change in the render tree is what triggers the transition. Transitions only animate when the current transaction contains an animation. In the example above, the .animation call causes the transition to animate.

The render tree does not actually exist with that name or form, but is simply a model for understanding how SwiftUI works. We're not completely sure how these things are represented under the hood.

When we change our view to have an if/else condition, things get a bit more interesting. Here's the code:

	struct ContentView: View {
    @State var visible = false
    var body: some View {
        VStack {
            Toggle("Visible", isOn: $visible)
            if visible {
                Text("Hello, world!")
            } else {
                Image(systemName: "hand.wave")
            }
        }
        .animation(.default, value: visible)
    }
}

When we render the initial view tree, it will contain a VStack with a Toggle and a Text. Once the state changes from false to true, the text is replaced by an image. In the ephemeral view tree there is always either the Text or the Image, never both. In the render tree however, during the animation the tree will contain both views:

Because we use the default transition, it looks like the text fades into the image and back. However, you can think of them as separate transitions: the text has a removal transition (fade out) and the image has an insertion transition (fade in).


We are not limited to the default fade transition. For example, here is a transition that slides in from the leading edge when a view is inserted, and removes the view by scaling it down:

	let transition = AnyTransition.asymmetric(insertion: .slide, removal: .scale)

We can then combine it with an .opacity (fade) transition. The .combined operator combines both transitions in parallel to get the following effect:

	let transition = AnyTransition.asymmetric(insertion: .slide, removal: .scale).combined(with: .opacity)
VStack {
    Toggle("Visible", isOn: $visible)
    if visible {
        Text("Hello, world!")
            .transition(transition)
    } else {
        Text("Hello world!")
            .transition(transition)
    }
}
.animation(.default.speed(0.5), value: visible)

Note that in the sample above, we used a visible value to switch between the two Texts, even though they are the same. We can simplify the code a bit by using id(_:). Whenever the value we pass to id changes, SwiftUI considers this to be a new view in the render tree. When we combine this with our knowledge of transitions, we can trigger a transition just by changing the id of a view. For example, we can rewrite the sample above:

	let transition = AnyTransition.asymmetric(insertion: .slide, removal: .scale).combined(with: .opacity)
VStack {
    Toggle("Visible", isOn: $visible)
    Text("Hello, world!")
        .id(visible)
        .transition(transition)
}
.animation(.default.speed(0.5), value: visible)

Before the animation, the text is present, and during the animation the newly inserted view (with id(false)) is transitioned in, and the old view (with id(true)) is transitioned out. In other words: both views are present during the animation:


When the builtin transitions don't cover your needs, you can also create custom transitions. There is the .modifier(active:identity) transition. When a view isn't transitioning, the identity modifier is applied. When a view is removed, the animation interpolates in between the identity modifier and the active modifier before removing the view completely. Likewise, when a view is inserted it starts out with the active modifier at the start of the animation, and ends with the identity modifier at the end of the animation.

Here's an example of a favorite button with a custom transition. This isn't a perfect implementation (we would not hardcode the offsets and width of the button) but it does show what's possible:

The full code is available as a gist.


Sometimes when performing a transition you might see unexpected side-effects. In our case we were almost always able to resolve these by wrapping the view we're transitioning inside a container (for example, a VStack or ZStack). This adds some "stability" to the view tree that can help prevent glitches.

In essence, transitions aren't very complicated. However, achieving the result you want can be a bit tricky sometimes. In order to effectively work with transitions you have to understand the difference between the view tree and the render tree. And when you want to have custom transitions, you also need to understand how animations work. We cover this in both our workshops and our book Thinking in SwiftUI.

If your company is interested in a workshop on SwiftUI, do get in touch.

Aspect Ratios in SwiftUI

2022年3月29日 06:00

One of the modifiers that always puzzled me a bit was .aspectRatio. How does it really work? Once I figured it out, it turned out to be simpler than I thought.

One place where we can find out a lot about how SwiftUI works is SwiftUI's .swiftinterface file. This is located inside of Xcode. Inside your Terminal, go to /Applications/Xcode.app, and perform the following command:

	find . -path "*/SwiftUI\.framework*swiftinterface"

There are a few variants of the .aspectRatio API, but they all boil down to a single implementation:

	func aspectRatio(_ aspectRatio: CGFloat?, contentMode: ContentMode) -> some View {
    // ...
}

The variant with CGSize just calls this method with size.width/size.height, and .scaledToFit and .scaledToFill call this method with the respective content modes and an aspectRatio of nil.

When we call aspectRatio with a fixed aspect ratio, e.g. .aspectRatio(16/9, contentMode: .fit), the aspect ratio implementation takes the proposed size, and proposes a new size to its child. When the content mode is .fit, it fits a rectangle with the desired aspect ratio inside the proposed size. For example, when you propose 100×100, it will propose 100×56.2 to its child. When you choose .fill instead, it will propose 177.8×100 to its child instead.

I figured out this behavior by printing the proposed sizes. More on that below.

Perhaps the most common use of aspectRatio is combined with a resizable image, like so:

	Image("test")
    .resizable()
    .aspectRatio(contentMode: .fit)

This will draw the image to fit within the proposed size. Note that we do not specify the actual aspect ratio: it is derived from the underlying image.

When we don't specify a fixed aspect ratio but use nil for the parameter, the aspect ratio modifier looks at the ideal size of the underlying view. This means it simply proposes nil×nil to the underlying view, and uses the result of that to determine the aspect ratio. For example, when the image reports its ideal size as 100×50, the computed aspect ratio is 100/50.

The process then continues like before: when the view was proposed 320×480, the image will be sized to 320×160 when the content mode is set to .fit, and 960×480 when the content mode is set to .fill.

Figuring out proposed sizes

Proposed sizes are not part of the public API of SwiftUI. Even though you absolutely need to understand how this works in order to write effective layouts, this isn't really documented. The only official place where this behavior is described is in the excellent 2019 WWDC talk Building Custom Views with SwiftUI.

However, there is a hack to do this. Inside the interface file mentioned above, I searched for "ProposedSize" and found a protocol named _ArchivableView which allows us to override sizeThatFits:

	struct MySample: _ArchivableView {
    var body: some View {
        Rectangle()
    }
    
    func sizeThatFits(in proposedSize: _ProposedSize) -> CGSize {
        print(proposedSize.pretty)
        return proposedSize.orDefault
    }
}

We can now simply construct a MySample with an aspect ratio and print the result. Instead of a .frame, you can also use .fixedSize() to propose nil for the width and/or height. Likewise, try leaving out the first parameter and see how .aspectRatio proposes nil to figure out the ideal size of its child view.

	MySample()
    .aspectRatio(100/50, contentMode: .fill)
    .frame(width: 320, height: 480)

Unfortunately the width and height properties on _ProposedSize aren't visible in the swift interface, so I had to use introspection to print those (and also add a few helper methods like .pretty and .orDefault). The full code is in a gist.

If you want to learn more about how SwiftUI works, read our book Thinking in SwiftUI. When your company is already building things in SwiftUI — or is about to get started — consider booking a SwiftUI Workshop for your team.

Advanced Swift: 5th edition

2022年3月18日 07:00

Today we're very happy to announce that the fifth edition of our book Advanced Swift is ready!

This is a big update, including a new chapter about concurrency, as well as many other additions about result builders, property wrappers, opaque types, existentials and more.

The book is available today, directly from our site. For the first time, the paper version of Advanced Swift is now available as a hardcover book directly from Amazon.

If you already own an Ebook edition of Advanced Swift, this is a free update (please use the download link in the original receipt).

Best from Berlin

Chris, Ole, and Florian

Transactions and Animations

2021年11月25日 07:00

In SwiftUI, there are many different ways to animate something on screen. You can have implicit animations, explicit animations, animated bindings, transactions, and even add animations to things like FetchRequest.

Implicit animations are animations that are defined within the view tree. For example, consider the following code. It animates the color of a circle between red and green:

	struct Sample: View {
    @State var green = false
    var body: some View {
        Circle()
            .fill(green ? Color.green : Color.red)
            .frame(width: 50, height: 50)
            .animation(.default)
            .onTapGesture {
                green.toggle()
            }
    }
}

This style of animation is called implicit because any changes to the subtree of the .animation call are implicitly animated. When you run this code as a Mac app, you will see a strange effect: on app launch, the position of the circle is animated as well. This is because the .animation(.default) will animate every time anything changes. We have been avoiding and warning against implicit animations for this reason: once your app becomes large enough, these animations will inevitably happen when you don't want them to, and cause all kinds of strange effects. Luckily, as of Xcode 13, these kind of implicit animations have been deprecated.

There is a second kind of implicit animation that does work as expected. This animation is restricted to only animate when a specific value changes. In our example above, we only want to animate whenever the green property changes. We can limit our animation by adding a value:

	struct Sample: View {
    @State var green = false
    var body: some View {
        Circle()
            .fill(green ? Color.green : Color.red)
            .frame(width: 50, height: 50)
            .animation(.default, value: green)
            .onTapGesture {
                green.toggle()
            }
    }
}

In our experience, these restricted implicit animations work reliably and don't have any of the strange side-effects that the unbounded implicit animations have.

You can also animate using explicit animations. With explicit animations, you don't write .animation in your view tree, but instead, you perform your state changes within a withAnimation block:

	struct Sample: View {
    @State var green = false
    var body: some View {
        Circle()
            .fill(green ? Color.green : Color.red)
            .frame(width: 50, height: 50)
            .onTapGesture {
                withAnimation(.default) {
                    green.toggle()
                }
            }
    }
}

When using explicit animations, SwiftUI will essentially take a snapshot of the view tree before the state changes, a snapshot after the state changes and animate any changes in between. Explicit animations also have none of the problems that unbounded implicit animations have.

However, sometimes you end up with a mix of implicit and explicit animations. This might raise a lot of questions: when you have both implicit and explicit animations, which take precedence? Can you somehow disable implicit animations when you're already having an explicit animation? Or can you disable any explicit animations for a specific part of the view tree?

To understand this, we need to understand transactions. In SwiftUI, every state change has an associated transaction. The transaction also carries all the current animation information. For example, when we write an explicit animation like above, what we're really writing is this:

	withTransaction(Transaction(animation: .default)) {
    green.toggle()
}

When the view's body is reexecuted, this transaction is carried along all through the view tree. The fill will then be animated using the current transaction.

When we're writing an implicit animation, what we're really doing is modifying the transaction for the current subtree. In other words, when you write .animation(.easeInOut), you're modifying the subtree's transaction.animation to be .easeInOut.

You can verify this with the .transaction modifier, which allows you to print (and modify) the current transaction. If you run the following code, you'll see that the inner view tree receives a modified transaction:

	Circle()
    .fill(green ? Color.green : Color.red)
    .frame(width: 50, height: 50)
    .transaction { print("inner", $0) }
    .animation(.easeInOut)
    .transaction { print("outer", $0) }

This answers our first question: the implicit animation takes precedence. When you have both implicit and explicit animations, the root transaction carries the explicit animation, but for the subtree with the implicit animation, the transaction's animation is overwritten.

This brings us to our second question: is there a way to disable implicit animations when we're trying to create an explicit animation? And let me spoil the answer: yes! We can set a flag disablesAnimations to disable any implicit animations:

	struct Sample: View {
    @State var green = false
    var body: some View {
        Circle()
            .fill(green ? Color.green : Color.red)
            .frame(width: 50, height: 50)
            .animation(.easeInOut, value: green)
            .onTapGesture {
                var t = Transaction(animation: .linear(duration: 2))
                t.disablesAnimations = true
                withTransaction(t) {
                    green.toggle()
                }
            }
    }
}

When you run the above code, you'll see that the transaction's animation takes precedence over the implicit animation. The flag disablesAnimations has a confusing name: it does not actually disable animations: it only disables the implicit animations.

To understand what's happening, let's try to reimplement .animation using .transaction. We set the current transaction's animation to the new animation unless the disablesAnimations flag is set:

	extension View {
    func _animation(_ animation: Animation?) -> some View {
        transaction {
            guard !$0.disablesAnimations else { return }
            $0.animation = animation
        }
    }
}

Note: An interesting side-effect of this is that you can also disable any .animation(nil) calls by setting the disablesAnimations property on the transaction. Note that you can also reimplement .animation(_:value:) using the same technique, but it's a little bit more work as you'll need to remember the previous value.

Let's look at our final question: can you somehow disable or override explicit animations for a subtree? The answer is "yes", but not by using .animation. Instead, we'll have to modify the current transaction:

	extension View {
    func forceAnimation(animation: Animation?) -> some View {
        transaction { $0.animation = animation }
    }
}

For me personally, transactions were always a bit of a mystery. Somebody in our SwiftUI Workshop asked about what happens when you have both implicit and explicit animations, and that's how I started to look into this. Now that I think I understand them, I believe that transactions are the underlying primitive, and both withAnimation and .animation are built on top of withTransaction and .transaction.

If you're interested in understanding how SwiftUI works, you should read our book Thinking in SwiftUI, watch our SwiftUI videos on Swift Talk, or even better: attend one of our workshops.

Why Conditional View Modifiers are a Bad Idea

2021年8月24日 06:00

In the SwiftUI community, many people come up with their own version of a conditional view modifier. It allows you to take a view, and only apply a view modifier when the condition holds. It typically looks something like this:

	// Please don't use this:
extension View {
    @ViewBuilder
    func applyIfM: View>(condition: Bool, transform: (Self) -> M) -> some View {
        if condition {
            transform(self)
        } else {
            self
        }
    }
}

There are many blog posts out there with similar modifiers. I think all these blog posts should come with a huge warning sign. Why is the above code problematic? Let's look at a sample.

In the following code, we have a single state property myState. When it changes between true and false, we want to conditionally apply a frame:

	struct ContentView: View {
    @State var myState = false
    var body: some View {
        VStack {
            Toggle("Toggle", isOn: $myState.animation())
            Rectangle()
                .applyIf(condition: myState, transform: { $0.frame(width: 100) })
        }
        // ...
    }
}

Interestingly, when running this code, the animation does not look smooth at all. If you look closely, you can see that it fades between the “before” and “after” state:

Here's the same example, but written without applyIf:

	struct ContentView: View {
    @State var myState = false
    var body: some View {
        VStack {
            Toggle("Toggle", isOn: $myState.animation())
            Rectangle()
                .frame(width: myState ? 100 : nil)
        }
        // ...
    }
}

And with the code above, our animation works as expected:

Why is the applyIf version broken? The answer teaches us a lot about how SwiftUI works. In UIKit, views are objects, and objects have inherent identity. This means that two objects are equal if they are the same object. UIKit relies on the identity of an object to animate changes.

In SwiftUI, views are structs — value types — which means that they don't have identity. For SwiftUI to animate changes, it needs to compare the value of the view before the animation started and the value of the view after the animation ends. SwiftUI then interpolates between the two values.

To understand the difference in behavior between the two examples, let's look at their types. Here's the type of our Rectangle().applyIf(...):

	_ConditionalContent<ModifiedContent<Rectangle, _FrameLayout>, Rectangle>

The outermost type is a _ConditionalContent. This is an enum that will either contain the value from executing the if branch, or the value from executing the else branch. When condition changes, SwiftUI cannot interpolate between the old and the new value, as they have different types. In SwiftUI, when you have an if/else with a changing condition, a transition happens: the view from the one branch is removed and the view for the other branch is inserted. By default, the transition is a fade, and that's exactly what we are seeing in the applyIf example.

In contrast, this is the type of Rectangle().frame(...):

	ModifiedContent<Rectangle, _FrameLayout>

When we animate changes to the frame properties, there are no branches for SwiftUI to consider. It can just interpolate between the old and new value and everything works as expected.

In the Rectangle().frame(...) example, we made the view modifier conditional by providing a nil value for the width. This is something that almost every view modifier support. For example, you can add a conditional foreground color by using an optional color, you can add conditional padding by using either 0 or a value, and so on.

Note that applyIf (or really, if/else) also breaks your animations when you are doing things correctly on the “inside”.

	Rectangle()
    .frame(width: myState ? 100 : nil)
    .applyIf(condition) { $0.border(Color.red) }

When you animate condition, the border will not animate, and neither will the frame. Because SwiftUI considers the if/else branches separate views, a (fade) transition will happen instead.

There is yet another problem beyond animations. When you use applyIf with a view that contains a @State property, all state will be lost when the condition changes. The memory of @State properties is managed by SwiftUI, based on the position of the view in the view tree. For example, consider the following view:

	struct Stateful: View {
    @State var input: String = ""
    var body: some View {
        TextField("My Field", text: $input)
    }
}

struct Sample: View {
    var flag: Bool
    var body: some View {
        Stateful().applyIf(condition: flag) {
            $0.background(Color.red)
        }
    }
}

When we change flag, the applyIf branch changes, and the Stateful() view has a new position (it moved to the other branch of a _ConditionalContent). This causes the @State property to be reset to its initial value (because as far as SwiftUI is concerned, a new view was added to the hierarchy), and the user's text is lost. The same problem also happens with @StateObject.

The tricky part about all of this is that you might not see any of these issues when building your view. Your views look fine, but maybe your animations are a little funky, or you sometimes lose state. Especially when the condition doesn't change all that often, you might not even notice.

I would argue that all of the blog posts that suggest a modifier like applyIf should have a big warning sign. The downsides of applyIf and its variants are not at all obvious, and I have unfortunately seen a bunch of people who have just copied this into their code bases and were very happy with it (until it became a source of problems weeks later). In fact, I would argue that no code base should have this function. It just makes it way too easy to accidentally break animations or state.

If you're interested in understanding how SwiftUI works, you could read our book Thinking in SwiftUI, watch our SwiftUI videos on Swift Talk, or attend one of our workshops.

❌
❌