Issue #916

WWDC23 brings new additions to SwiftUI

Screenshot 2023-06-07 at 22 02 36

Scrolling

The scroll transition modifier is very similar to the visual effect modifier Curt used earlier for the welcome screen. It lets you apply effects to items in your scroll view.

I’m using the new containerRelativeFrame modifier to size these park cards relative to the visible size of the horizontal scroll view.

I’d like my park cards to snap into place. The new scrollTargetLayout modifier makes that easy. I’ll add it to the LazyHStack and modify the scroll view to align to views in the targeted layout.

In addition to view alignment, scroll views can also be defined to use a paging behavior. And for a truly custom experience, you can define your own behavior using the scrollTargetBehavior protocol.

The new scrollPosition modifier takes a binding to the topmost item’s ID, and it’s updated as I scroll.

https://github.com/onmyway133/blog/assets/2284279/2f542d7f-cfe6-4e6e-8778-9318f74312bd

struct ScrollingRecentDogsView: View {
    private static let colors: [Color] = [.red, .blue, .brown, .yellow, .purple]

    private var dogs: [Dog] = (1..<10).map {
        .init(
            name: "Dog \($0)", color: colors[Int.random(in: 0..<5)],
            isFavorite: false)
    }

    private var parks: [Park] = (1..<10).map { .init(name: "Park \($0)") }

    @State private var scrolledID: Dog.ID?

    var body: some View {
        ScrollView {
            LazyVStack {
                ForEach(dogs) { dog in
                    DogCard(dog: dog, isTop: scrolledID == dog.id)
                        .scrollTransition { content, phase in
                            content
                                .scaleEffect(phase.isIdentity ? 1 : 0.8)
                                .opacity(phase.isIdentity ? 1 : 0)
                        }
                }
            }
        }
        .scrollPosition(id: $scrolledID)
        .safeAreaInset(edge: .top) {
            ScrollView(.horizontal) {
                LazyHStack {
                    ForEach(parks) { park in
                        ParkCard(park: park)
                            .aspectRatio(3.0 / 2.0, contentMode: .fill)
                            .containerRelativeFrame(
                                .horizontal, count: 5, span: 2, spacing: 8)
                    }
                }
                .scrollTargetLayout()
            }
            .scrollTargetBehavior(.viewAligned)
            .padding(.vertical, 8)
            .fixedSize(horizontal: false, vertical: true)
            .background(.thinMaterial)
        }
        .safeAreaPadding(.horizontal, 16.0)
    }

Inspector

Inspector is a new modifier for displaying details about the current selection or context. It’s presented as a distinct section in your interface. On macOS, Inspector presents as a trailing sidebar. as well as on iPadOS in a regular size class. In compact size classes, it will present itself as a shee

import SwiftUI

struct InspectorContentView: View {
    @State private var inspectorPresented = true
    
    var body: some View {
        DogTagEditor()
            .inspector(isPresented: $inspectorPresented) {
                DogTagInspector()
            }
    }
    
    struct DogTagEditor: View {
        var body: some View {
            Color.clear
        }
    }
    
    struct DogTagInspector: View {
        @State private var fontName = FontName.sfHello
        @State private var fontColor: Color = .white
        
        var body: some View {
            Form {
                Section("Text Formatting") {
                    Picker("Font", selection: $fontName) {
                        ForEach(FontName.allCases) {
                            Text($0.name).tag($0)
                        }
                    }

                    ColorPicker("Font Color", selection: $fontColor)
                }
            }
        }
    }

Modifiers

By applying the allowedDynamicRange modifier, the beautiful images in our app’s gallery screen can be shown with their full fidelity

Screenshot 2023-06-07 at 22 11 04

I’m also going to add the new accessibilityZoomAction modifier to my view. This allows assistive technologies like VoiceOver to access the same functionality without using the gesture

Screenshot 2023-06-07 at 22 11 34

Color now supports using static member syntax to look up custom colors defined in your app’s asset catalog. This gives compile-time safety when using them, so you’ll never lose time to a typo

Screenshot 2023-06-07 at 22 11 59

Lastly, the paletteSelectionEffect modifier lets me use a symbol variant to represent the selected item in the picker.

Screenshot 2023-06-07 at 22 12 26

Focusable views on platforms with hardware keyboard support can use the onKeyPress modifier to directly react to any keyboard input. The modifier takes a set of keys to match against and an action to perform for the event

Screenshot 2023-06-07 at 22 14 26

An increased severity helps draw attention to important confirmation dialogs. HelpLink can be a guide to further information about the purpose of the dialog

Screenshot 2023-06-07 at 22 17 14

Presentation modifiers allow deep customization of sheets and other presentations like popovers

Screenshot 2023-06-07 at 23 55 10
.sheet(item: $nibbledFruit) { fruit in
  FruitNibbleBulletin(fruit: fruit)
    .presentationBackground(.thinMaterial)
    .presentationDetents([.height(200), .medium, .large])
    .presentationBackgroundInteraction(.enabled(upThrough: .height(200)))
}

Observable

When body is executed, SwiftUI tracks all access to properties used from ‘Observable’ types. It then takes that tracking information and uses it to determine when the next change to any of those properties on those specific instances will change

@Observable class Donut {
  var name: String
}

struct DonutView: View {
  @Bindable var donut: Donut

  var body: some View {
    TextField("Name", text: $donut.name)
  }
}

Does this model need to be state of the view itself? If so, use ‘@State’. Does this model need to be part of the global environment of the application? If so, use ‘@Environment’. Does this model just need bindings? If so, use the new ‘@Bindable’. And if none of these questions have the answer as yes, just use the model as a property of your view

Screenshot 2023-06-07 at 21 37 14

Manual observation

@Observable class Donut {
  var name: String {
    get {
      access(keyPath: \.name)
      return someNonObservableLocation.name 
    }
    set {
      withMutation(keyPath: \.name) {
        someNonObservableLocation.name = newValue
      }
    }
  } 
}

The ‘@EnvironmentObject’ wrappers got transformed into just ‘@Environment’.

@Observable class Account {
  var userName: String?
}

struct FoodTruckMenuView : View {
  @Environment(Account.self) var account

  var body: some View {
    if let name = account.userName {
      HStack { Text(name); Button("Log out") { account.logOut() } }
    } else {
      Button("Login") { account.showLogin() }
    }
  }
}

SFSymbol

Just apply this modifier to animate an SF Symbol, or all the symbols in a view hierarchy. Symbols support a variety of effects, including continuous animations with pulse and variable color.

State changes with scale, appear and disappear, and replace, and event notifications with bounce

Screenshot 2023-06-07 at 22 00 43
Image(systemName: "").symbolEffect()

Metal Shader

Using SwiftUI’s new ShaderLibrary, I can turn Metal shader functions directly into SwiftUI shape styles. If you’d like to take Metal shaders out for a spin, just add a new Metal file to your project and call your shader function using ShaderLibrary in SwiftUI.

https://github.com/onmyway133/blog/assets/2284279/85ccc786-beb5-49fd-843b-c33383cbb7ab

import SwiftUI

struct ShaderUse_Snippet: View {
    @State private var stripeSpacing: Float = 10.0
    @State private var stripeAngle: Float = 0.0

    var body: some View {
        VStack {
            Text(
                """
                \(
                    Text("Furdinand")
                        .foregroundStyle(stripes)
                        .fontWidth(.expanded)
                ) \
                is a good dog!
                """
            )
            .font(.system(size: 56, weight: .heavy).width(.condensed))
            .lineLimit(...4)
            .multilineTextAlignment(.center)
            Spacer()
            controls
            Spacer()
        }
        .padding()
    }

    var stripes: Shader {
        ShaderLibrary.angledFill(
            .float(stripeSpacing),
            .float(stripeAngle),
            .color(.blue)
        )
    }

Widget

Note how I am using the containerBackground modifier here to define the background for my widget. This allows it to show up in all the new supported locations on the Mac and iPad

.containerBackground(for: .widget) {
    Color.cosmicLatte
}

There is a new family of initializers on Button and Toggle that accept an AppIntent as an argument and will execute that intent when these controls are interacted with. Note that only Button and Toggle using AppIntent are supported in interactive widgets.

struct LogDrinkView: View {
    var body: some View {
        Button(intent: LogDrinkIntent(drink: .espresso)) {
            Label("Espresso", systemImage: "plus")
                .font(.caption)
        }
        .tint(.espresso)
    }
}

New for widgets this year are content margins. Content margins are padding which is automatically applied to your widget’s body, preventing your content from getting to close to the edge of the widget container. These margins may be larger or smaller, depending on the environment where your widget is being shown

On watchOS 10 and above, safe areas in widgets have been replaced by the use of content margins. This means that modifiers like ignoresSafeArea no longer have any effect in widgets

However, if your widget has content which used to ignore the safe area, you can still achieve this same effect by adding the contentMarginsDisabled modifier to your widget configuration

Screenshot 2023-06-07 at 22 29 15
struct SafeAreasWidgetView: View {
    @Environment(\.widgetContentMargins) var margins

    var body: some View {
        ZStack {
            Color.blue
            Group {
                Color.lightBlue
                Text("Hello, world!")
            }
                .padding(margins) 
        }
    }
}

iPad can also show system small widgets right alongside them

To make the background color removable, all we need to change is to add a containerBackground modifier to our View, and move our gameBackground color inside. Once we do that, the system can automatically take out our widget’s background depending on where it’s being shown

Screenshot 2023-06-07 at 22 30 47
struct EmojiRangerWidgetEntryView: View {
    var entry: Provider.Entry
    
    @Environment(\.widgetFamily) var family

    var body: some View {
        switch family {
        case .systemSmall:
            ZStack {
                AvatarView(entry.hero)
                    .widgetURL(entry.hero.url)
                    .foregroundColor(.white)
            }
            .containerBackground(for: .widget) {
                Color.gameBackground
            }
        }
        // additional cases
    }
}

We can detect whether the widget background has been removed using the showsWidgetContainerBackground environment variable.

Screenshot 2023-06-07 at 22 34 14

Just like with accessory family widgets, our system family widgets are shown in the vibrant rendering mode on iPad Lock screen. We can use the widgetRenderingMode environment variable to detect which rendering mode we’re in

SwiftData

@Model is a new Swift macro that helps to define your model’s schema from your Swift code.

@Model
class Trip {
    @Attribute(.unique) var name: String
    var destination: String
    var endDate: Date
    var startDate: Date
 
    @Relationship(.cascade) var bucketList: [BucketListItem]? = []
    var livingAccommodation: LivingAccommodation?
}

New in iOS 17, predicate works with native Swift types and uses Swift macros for strongly typed construction. It’s a fully type checked modern replacement for NSPredicate

let today = Date()
let tripPredicate = #Predicate<Trip> { 
    $0.destination == "New York" &&
    $0.name.contains("birthday") &&
    $0.startDate > today
}

Fetch and sort descriptor

let descriptor = FetchDescriptor<Trip>(
    sortBy: SortDescriptor(\Trip.name),
    predicate: tripPredicate
)

let trips = try context.fetch(descriptor)

SwiftData supports the all-new observable feature for your modeled properties. SwiftUI will automatically refresh changes on any of the observed properties

import SwiftUI

struct ContentView: View  {
    @Query(sort: \.startDate, order: .reverse) var trips: [Trip]
    @Environment(\.modelContext) var modelContext
    
    var body: some View {
       NavigationStack() {
          List {
             ForEach(trips) { trip in 
                 // ...
             }
          }
       }
    }
}

Animation

An enum is a great way to define a list of steps for the animation When we give the phase animator modifier a trigger value, it observes the value that you specify for changes. And when a change occurs, it begins animating through the phases that you specify.

When a state transition occurs, all of the properties are animated at the same time. And then, when that animation is finished, SwiftUI animates to the next state. And this continues across all of the phases of the animation

https://github.com/onmyway133/blog/assets/2284279/e6486231-6084-41b2-bc72-502e20fcaeaf

ReactionView()
    .phaseAnimator(
        Phase.allCases, 
        trigger: reactionCount
    ) { content, phase in
        content
            .scaleEffect(phase.scale)
            .offset(y: phase.verticalOffset)
    } animation: { phase in
        switch phase {
        case .initial: .smooth
        case .move: .easeInOut(duration: 0.3)
        case .scale: .spring(
            duration: 0.3, bounce: 0.7)
        } 
    }

enum Phase: CaseIterable {
    case initial
    case move
    case scale

    var verticalOffset: Double {
        switch self {
        case .initial: 0
        case .move, .scale: -64
        }
    }

    var scale: Double {
        switch self {
        case .initial: 1.0
        case .move: 1.1
        case .scale: 1.8
        }
    }
}

Keyframes let you build sophisticated animations with different keyframes for different properties. To make this possible, keyframes are organized into tracks. Each track controls a different property of the type that you are animating, which is specified by the key path that you provide when creating the track

We first add a linear keyframe, repeating the initial scale value and holding it for 0.36 seconds

https://github.com/onmyway133/blog/assets/2284279/75403838-dc6f-4580-b6dd-299a60ea51c0

ReactionView()
    .keyframeAnimator(initialValue: AnimationValues()) { content, value in
        content
            .foregroundStyle(.red)
            .rotationEffect(value.angle)
            .scaleEffect(value.scale)
            .scaleEffect(y: value.verticalStretch)
            .offset(y: value.verticalTranslation)
        } keyframes: { _ in
            KeyframeTrack(\.angle) {
                CubicKeyframe(.zero, duration: 0.58)
                CubicKeyframe(.degrees(16), duration: 0.125)
                CubicKeyframe(.degrees(-16), duration: 0.125)
                CubicKeyframe(.degrees(16), duration: 0.125)
                CubicKeyframe(.zero, duration: 0.125)
            }

            KeyframeTrack(\.verticalStretch) {
                CubicKeyframe(1.0, duration: 0.1)
                CubicKeyframe(0.6, duration: 0.15)
                CubicKeyframe(1.5, duration: 0.1)
                CubicKeyframe(1.05, duration: 0.15)
                CubicKeyframe(1.0, duration: 0.88)
                CubicKeyframe(0.8, duration: 0.1)
                CubicKeyframe(1.04, duration: 0.4)
                CubicKeyframe(1.0, duration: 0.22)
            }
            
            KeyframeTrack(\.scale) {
                LinearKeyframe(1.0, duration: 0.36)
                SpringKeyframe(1.5, duration: 0.8, spring: .bouncy)
                SpringKeyframe(1.0, spring: .bouncy)
            }

            KeyframeTrack(\.verticalTranslation) {
                LinearKeyframe(0.0, duration: 0.1)
                SpringKeyframe(20.0, duration: 0.15, spring: .bouncy)
                SpringKeyframe(-60.0, duration: 1.0, spring: .bouncy)
                SpringKeyframe(0.0, spring: .bouncy)
            }
        }

struct AnimationValues {
    var scale = 1.0
    var verticalStretch = 1.0
    var verticalTranslation = 0.0
    var angle = Angle.zero
}

Keyframes can be manually evaluated to drive any kind of effect that you can think of. Outside of the modifier, you can use the “KeyframeTimeline” type to capture a set of keyframes and tracks. You initialize this type with an initial value, and the keyframe tracks that define your animation, just like with the view modifier.

let myKeyframes = KeyframeTimeline(initialValue: CGPoint.zero) {
    KeyframeTrack(\.x) {...}
    KeyframeTrack(\.y) {...}
}

// Duration in seconds
let duration: TimeInterval = myKeyframes.duration

// Value for time
let value = myKeyframes.value(time: 1.2)

StoreKit

StoreKit loads all the product identifiers from the App Store and presents them in UI for us to view

Screenshot 2023-06-07 at 21 25 37
import SwiftUI
import StoreKit

struct BirdFoodShop: View {
    @Query var birdFood: [BirdFood]
    
    var body: some View {
        StoreView(ids: birdFood.productIDs) { product in
            BirdFoodShopIcon(productID: product.id)
        }
        .productViewStyle(.compact)
    }
}

The SubscriptionStoreView manages the data flow for us and lays out a view with the different plan options. It also checks for existing subscriber status and whether the customer is eligible for an introductory offer

Screenshot 2023-06-07 at 21 28 39
struct BackyardBirdsPassShop: View {
    @Environment(\.shopIDs.pass) var passGroupID
 
    var body: some View {
        SubscriptionStoreView(groupID: passGroupID) {
            PassMarketingContent()
                .lightMarketingContentStyle()
                .containerBackground(for: .subscriptionStoreFullHeight) {
                    SkyBackground()
                }
        }
        .backgroundStyle(.clear)
    }
  
}

In addition to onInAppPurchaseCompletion, there are a few other related view modifiers you can use to handle events from StoreKit views.

BirdFoodShop()
    .onInAppPurchaseStart { (product: Product) in
        self.isPurchasing = true
    }

TipKit

TipKit can teach someone about a brand-new feature, help with discovery of a hidden feature, or show a faster way to accomplish a task

Screenshot 2023-06-07 at 21 33 01 Screenshot 2023-06-07 at 21 34 53 Screenshot 2023-06-07 at 21 35 03
struct FavoriteBackyardTip: Tip {

    @Parameter
    static var isLoggedIn: Bool = false
  
    static let enteredBackyardDetailView: Event = Event<DetailViewDonation>(
        id: "entered-backyard-detail-view"
    )

    // ...
    
    var rules: Predicate<RuleInput...> {
        // User is logged in
        #Rule(Self.$isLoggedIn) { $0 == true }
            
        // User has entered any backyard detail view at least 3 times
        #Rule(Self.enteredBackyardDetailView) { $0.count >= 3 }
    }
}

.toolbar {
    ToolbarItem {
        Button {
            backyard.isFavorite.toggle()
        } label: {
            Label("Favorite", systemImage: "star")
                .symbolVariant(
                    backyard.isFavorite ? .fill : .none
                )
        }
        .popoverMiniTip(tip: favoriteBackyardTip)
    }
}

There are two main types of rules. The first is a parameter-based rule. Parameter-based rules are persistent and are best suited for showing tips based on a Swift value type that you want to write an expression around. The second is an event-based rule. Event-based rules allow you to define an action that must be performed before a person becomes eligible for a tip.

TipsCenter.shared.configure {
    DisplayFrequency(.daily)
}

SwiftChart

Swift Charts brings a slew of great improvements, including scrolling charts, built-in support for selection

Screenshot 2023-06-07 at 22 41 35

Donut and pie charts with the new SectorMark

Screenshot 2023-06-07 at 22 40 57

Accessibility

With AccessibilityNotification, you can send announcement, layout change, screen change, and page scroll notifications in a way that is native to Swift

import SwiftUI

struct ContentView: View {
    var body: some View {
        NavigationView {
            PhotoFilterView
                .toolbar {
                    Button(action: {
                        AccessibilityNotification.Announcement("Loading Photos View")
                            .post()
                    }) {
                        Text("Photos")
                    }
                }
        }
    }
}

We then implement accessibilityZoomInAtPoint and accessibilityZoomOutAtPoint, each of which return a boolean to indicate zooming success or failure. In each of these methods, we update the zoom scale and post an announcement to indicate the zoom change

struct ZoomingImageView: View {
    @State private var zoomValue = 1.0
    @State var imageName: String?

    var body: some View {
        Image(imageName ?? "")
            .scaleEffect(zoomValue)
            .accessibilityZoomAction { action in
                let zoomQuantity = "\(Int(zoomValue)) x zoom"
                switch action.direction {
                case .zoomIn:
                    zoomValue += 1.0
                    AccessibilityNotification.Announcement(zoomQuantity).post()
                case .zoomOut:
                    zoomValue -= 1.0
                    AccessibilityNotification.Announcement(zoomQuantity).post()
                }
            }
    }
}

This is a great time to adopt the direct touch trait, named allowsDirectInteraction, on our view. Accessibility direct touch areas will let you specify a region of the screen where VoiceOver gestures pass directly through to the app

In addition to the allowsDirectInteraction trait, there are now two new direct touch options that will be supported. First, you can specify silentOnTouch to ensure VoiceOver is silent when touching the direct touch area, so that your app can make its own audio feedback

Second, you can specify requiresActivation to make the direct touch area require VoiceOver to activate the element before touch passthrough happens

import SwiftUI

struct KeyboardKeyView: View {
    var soundFile: String
    var body: some View {
        Rectangle()
            .fill(.white)
            .frame(width: 35, height: 80)
            .onTapGesture(count: 1) {
                playSound(sound: soundFile, type: "mp3")
            }            
            .accessibilityDirectTouch(options: .silentOnTouch)
    }
}