Now Reading
SwiftUI is handy, however gradual — Alin Panaitiu

SwiftUI is handy, however gradual — Alin Panaitiu

2023-08-26 10:55:58

I have been sitting on publishing these notes for some time, as a result of I do not need to diminish the work of the gifted those who engineered SwiftUI and plowed by means of years of AppKit and UIKit code to get to this state.

However I might like to attract consideration to some efficiency limitations, within the hope {that a} SwiftUI engineer may see this and perceive ache factors that may not be so apparent from their facet.

I constructed a calendar app for macOS. There are numerous calendar apps on the market, however this one is mine, and I wished it to suit two very particular use instances:

  • keyboard-oriented UI and navigation
  • at all times one key-press away

That is the How?, an concept of the behaviour that the calendar ought to have when it is executed – however the Why? is a little bit of an extended story.

In October 2022, my little brother began faculty, and transferring from the countryside to a giant metropolis meant massive prices of dwelling that my mother and father weren’t ready for. I wished to help him one way or the other.

To help himself, he at the moment has to work 12 hours a day, 5 days per week (together with weekends) at a espresso store. He barely sees the school constructing.

I believed I may construct a low effort, low upkeep, inexpensive however nonetheless helpful app, from which all of the proceeds will go in the direction of funding his prices. I truly tried that in the summer however the App Retailer rejections rendered my efforts ineffective.

So sifting by means of my famous app concepts, I believed a keyboard-driven macOS calendar could be the best to construct, particularly with the latest SwiftUI additions.

In spite of everything it is only a grid of numbers that does not even have to be clickable, how arduous may or not it’s? (I am saying that loads, aren’t I?..)


# The SwiftUI comfort

After per week of lengthy days stretching into the night time, I had this factor working that I referred to as Grila (Romanian phrase for “grid”)

In 7 days I had 3 totally different views (yr, month and 3-months), gentle and darkish mode help, customisable shade schemes, keyboard navigation and assist hints to make it discoverable, and better of all: ✨ ANIMATIONS! ✨

These rattling animations man.. they’re so fluid and springy and straightforward to make use of in SwiftUI, that it lures you into including them all over the place.

Fluid animation when toggling Assist in Grila

# The grid

I began with the one apparent grid factor for laying out the weeks as rows of days: LazyHGrid

LazyHGrid(rows: (1...7).map { _ in GridItem(.fastened(30)) }) {
    ForEach(days) { day in
        DayView(day: day)
    }
}

And it was clearly unsuitable for my use case…

This container is lazy so it takes extra time get the view rendered as a result of SwiftUI has to test if every row is seen earlier than rendering it.

Since there was no different grid factor, I needed to write my very own utilizing HStacks inside VStacks. This took longer than I anticipated as a result of I needed to compute what number of weeks a month has and on what column it begins and ends. As ordinary, fixing an edge case brings with it three extra.

However after this hiccup, nearly every little thing that adopted felt surprisingly straightforward to construct.

# The (over)views

For instance I wished to have the ability to change between a full yr view and month views, which additionally meant the window needed to be resized to suit every view. As quickly as I had my separate views, this was all I needed to do in my predominant view:

enum GridZoom {
    case month
    case multiMonth
    case yr
}

struct MainView: View {
    @State var zoom: GridZoom

    var physique: some View {
        change zoom {
            case .month:
                MonthView().fixedSize()
            case .multiMonth:
                MultiMonthView().fixedSize()
            case .yr:
                YearView().fixedSize()
        }
    }
}

The .fixedSize() modifier would resize the window robotically, and easily setting the zoom property would render the suitable view.

Grila switching between views

# Coloration schemes

I additionally wished to have configurable shade schemes as a result of not everybody likes the identical colours as I do, and since generally I become bored with the chosen colours myself.

With 3 sliders for hue, saturation and distinction, and some built-in SwiftUI modifiers, I had created the best strategy to regulate the colour palette:

struct ColorPreferencesView: View {
    @AppStorage("hue") var hue = 30.0
    @AppStorage("saturation") var saturation = 1.0
    @AppStorage("distinction") var distinction = 1.0

    var physique: some View {
        Slider(worth: $hue, in: 0.0 ... 359.0, label: { Textual content("Hue") })
        Slider(worth: $saturation, in: 0.0 ... 2.0, label: { Textual content("Saturation") })
        Slider(worth: $distinction, in: 0.0 ... 2.0, label: { Textual content("Distinction") })
    }
}

struct ContentView: View {
    @AppStorage("hue") var hue = 30.0
    @AppStorage("saturation") var saturation = 1.0
    @AppStorage("distinction") var distinction = 1.0

    var physique: some View {
        MainView()
            .hueRotation(.levels(hue))
            .saturation(saturation)
            .distinction(distinction)
    }
}

Grila shade scheme sliders

# Keyboard-oriented UI

Reacting to keyboard occasions remains to be one thing I do exterior of SwiftUI after I want single-key shortcuts (e.g. / as an alternative of ⌘/).

That is simply executed by utilizing a neighborhood occasion monitor:

NSEvent.addLocalMonitorForEvents(matching: .keyDown) { occasion in
    change occasion.keyCode {
        case kVK_ANSI_Slash:
            let showHelp = UserDefaults.commonplace.bool(forKey: "showHelp")
            UserDefaults.commonplace.set(!showHelp, forKey: "showHelp")
        // case NUMBERS:
        //     changeDay()
        // case LETTERS:
        //     changeMonth()
        default:
            return occasion
    }

    return nil
}

struct ContentView: View {
    @AppStorage("showHelp") var showHelp = false

    var physique: some View {
        MainView()

        if showHelp {
            HelpHints()
        }
    }
}

Grila altering day and month


# Efficiency pitfalls

I by no means create apps only for different folks, I’ve to have a necessity for that app myself in order that I perceive each the issue and the answer. Additionally options often must evolve with the world and the fixed expertise adjustments, in any other case the app turns into out of date quick.

I additionally by no means launch apps as quickly as they’re executed. I take advantage of them myself for weeks, so I can discover all of the creaks and rattles in actual world utilization earlier than getting them within the fingers of individuals.

So after that week of constructing, precisely within the first day after, a good friend calls to inform me they’d like to go to from one other metropolis in a couple of month. When’s one of the best time?

Good, precisely what I made Grila for! No extra must launch Alfred or Highlight, sort “calendar”, hit enter, look bedazzled on the “Week view? what the heck is that this for?”, determine the best way to change to the month view, oh wait, I’ve to consistently change between November and December.. “possibly the yr view is best?” nope, no occasions are seen there.

Now I simply press Proper Choice+ and have each November and December and occasions in my face immediately!

Properly probably not.

Janky animation when toggling Assist in Grila

It took about two seconds and a cringing janky animation to get Grila rendered on the display. Each key press made me decelerate my considering as a result of there was a 1 second latency till the motion modified one thing within the UI. It made me really feel like I went again to 2008 on my Core 2 Duo PC operating Home windows Vista.

What was totally different? This time I had Low Energy Mode lively on my M1 Max MacBook.

Even with LPM, this CPU remains to be extremely quick. If Grila struggles a lot on it, I am unable to even think about how unhealthy it could behave on older Intel Macs.

# Devices for investigation

macOS has a strong troubleshooting utility for apps referred to as Devices.

Attaching its Time Profiler to Grila confirmed that the heaviest stack hint was in some SwiftUI graph updating code. Have a look at this factor!

Click on on it, I dare you.


grila heavy stack trace

Sure, that is how a lot I understood from it as nicely.

Because the stack hint is of no use, I attempted the SwiftUI profiling template from Devices.

What appears to be like uncommon is the large variety of updates on my customized views. Some updates additionally take 5ms or extra which when referred to as on the primary thread, will block any UI rendering and animations.

Possibly I used to be sloppy and too liberal with my view states, I ought to enhance on this.

# Optimisations (and wacky workarounds)

The slowest factor was the yr view. As a result of it needed to render greater than 365 views, every small change was excruciatingly gradual.

I lowered the variety of views by consolidating every month’s day views right into a single, rigorously aligned textual content string.

So this:

struct WeekRow: View {
    @State var week: Int

    var physique: some View {
        ForEach(days(week)) { day in
            Textual content(day)
        }
    }
}

struct YearMonth: View {
    var physique: some View {
        VStack {
            WeekRow(week: 1)
            WeekRow(week: 2)
            WeekRow(week: 3)
            WeekRow(week: 4)
            WeekRow(week: 5)
        }
    }
}

Turned this:

struct YearMonth: View {
    var physique: some View {
        Textual content(generateMonthText())
            .font(.physique.monospaced())
            // Utilizing the `monospaced` font makes
            // aligning textual content columns a lot simpler
    }
}

Yr view was now a lot sooner, however partly neutered as I had no means of displaying occasions or different forms of info beneath every day. Oh nicely, that does not imply a lot anyway if I am unable to even make this app usable.


The three-months view had no apparent low hanging fruit. The View Properties Updates profiler confirmed that there have been a ton of updates inflicting the physique of the view to be referred to as many occasions, but it surely was not possible to determine what mattered.

In some way in my tireless web sleuthing, I stumbled upon a SwiftUI private function that prints the adjustments which brought on a physique replace: Self._printChanges()

So I may now insert that line into my DayView and work my means as much as the MultiMonthView till I found out what adjustments are pointless.

# States and Bindings

The phrases that you’re going to come upon essentially the most in SwiftUI are @State and @Binding, that are property wrappers for outlining native state and two-way bindings.

You see, there’s this factor that occurs loads when creating SwiftUI views with deeper hierarchies:

  • You create BigContainingView with @State var someProp
  • Inside you name SmallView(prop: someProp)
  • You alter someProp exterior
  • Why is not SmallView updating?
  • You add @Binding var prop into SmallView
    • and name it utilizing SmallView(prop: $someProp) (discover the added $)
  • Ah good, the view is now updating

Due to this situation, I had created a shortcut for passing one-way bindings, and due to this shortcut, my listing continued with:

  • Efficiency points..
  • Why is SmallView NOW CONSTANTLY UPDATING?

Within the following instance, I wanted a strategy to spotlight the chosen day, however passing chosen as @State would not propagate by means of the view graph. So I made it a binding, which brought on all the DayViews to replace when deciding on one other day (as an alternative of simply the 2 days that really modified):

extension Binding {
    static func oneway(getter: @escaping () -> Worth) -> Binding {
        Binding(get: getter, set: { _ in })
    }
}

struct DayView: View {
    @State var date: Date
    @Binding var chosen: Bool

    ...
}

struct MonthView: View {
    @State var selectedDate: Date

    var physique: some View {
        ForEach(dates) { date in
            DayView(date: date, chosen: .oneway { date == selectedDate })
        }
    }
}

----------------------------------

// Logs from Self._printChanges()

DayView: _selected modified.
DayView: _selected modified.
DayView: _selected modified.
// ... repeated 77 occasions

The answer I discovered for this was to show the Binding again right into a State and manually add an .id() to the DayView that elements within the chosen property.

That means, the graph algorithm will evaluate the brand new id with the outdated one, and if chosen is totally different, the view will get redrawn.

struct MonthView: View {
    @State var selectedDate: Date

    var physique: some View {
        ForEach(dates) { date in
            let isSelected = date == selectedDate

            DayView(date: date, chosen: isSelected)
                .id("(date)-(isSelected)")
        }
    }
}

This variation alone decreased the variety of pointless view updates to 1/4 of what it was after I began debugging.

# ObservableObject

One other sample I are likely to overuse is having a worldwide object with @Printed properties that I can change from AppDelegate and have their adjustments seen by SwiftUI views.

See Also

class SomeGlobalObject: ObservableObject {
    @Printed var fooDayView = true
    @Printed var barHintView = false
}

let GLOBAL = SomeGlobalObject()


struct DayView: View {
    @ObservedObject var international = GLOBAL

    // ... makes use of fooDayView someplace
}

struct HintView: View {
    @ObservedObject var international = GLOBAL

    // ... makes use of barHintView someplace
}


// someplace in AppDelegate

barHintView.toggle()

----------------------------------

// Logs from Self._printChanges()

DayView: _global modified.
DayView: _global modified.
DayView: _global modified.
...
HintView: _global modified.

DayView does not use the modified property, so all these updates may have been skipped solely.
On this case, I needed to recover from my tendency to not repeat myself, and simply cross the properties as parameters.

There are different methods to repair this, every with its personal quirks:

  • Retailer these properties in UserDefaults
    • This makes them persistent on disk which could not be fascinating
  • Outline a number of, extra particular international objects with much less properties
    • It turns into arduous to recollect which object had which property
  • Cross the values as EnvironmentValues
    • This wants much more boilerplate
    • Useful if the property is required deep within the view hierarchy

Here is the EnvironmentValues various for example:

struct SelectedDateKey: EnvironmentKey {
    static let defaultValue = Date()
}

extension EnvironmentValues {
    var selectedDate: Date {
        get { self[SelectedDateKey.self] }
        set { self[SelectedDateKey.self] = newValue }
    }
}

extension View {
    func selectedDate(_ selectedDate: Date) -> some View {
        setting(.selectedDate, selectedDate)
    }
}

struct ContentView: View {
    var physique: some View {
        MainView()
            .selectedDate(globalSelectedDate)
    }
}

struct SomeViewDeepDownInTheGraph: View {
    @Surroundings(.selectedDate) var selectedDate

    var physique: some View {
        Textual content(selectedDate.formatted())
    }
}

# Animations

After they’re executed accurately, animations can convey info that will in any other case be lacking. Like the place a component was moved after a format change, or how a view was resized when extra parts appeared inside it.

SwiftUI makes this particular case straightforward to implement utilizing .matchedGeometryEffect:

struct ContentView: View {
    @Namespace var namespace

    var physique: some View {
        HStack {
            if showHelp {
                PinWindowButton(showHint: true)
                    .matchedGeometryEffect(id: "pinWindowButton", in: namespace)

                Spacer()
                HelpHints()
            } else {
                PinWindowButton(showHint: false)
                    .matchedGeometryEffect(id: "pinWindowButton", in: namespace)
            }
        }

        MainView()
    }
}

Animations don’t at all times need to be sensible although. They are often added solely for his or her whimsical impact.

I personally find it irresistible after I’m mesmerized by an consumer interface that seems natural and nearer to actual life motion. These interfaces really feel way more private.

Springs in SwiftUI can create that look with out having to tune any parameter:

Button("Toggle Assist Hints") {
    withAnimation(.spring()) {
        showHelp.toggle()
    }
}

// Toggling showHelp will transfer the PinWindowButton and alter its measurement
//
// SwiftUI will compute the distinction between the outdated and new format,
// and animate that distinction utilizing the `.spring` animation

Grila pin window animation

However plenty of the time, animations are overused and may’t even be turned off.

I keep in mind the primary time I used a MacBook trackpad in 2015, swiping with three fingers to maneuver by means of Areas. It felt magical.

Lower than one yr later, the identical animation was driving me nuts as a result of I used to be switching areas so typically, that the animation was slowing me down. There’s no strategy to disable that animation from the system, you possibly can solely cut back it to a crossfade.

Yabai can truly disable it by altering the system window supervisor code in reminiscence, however that requires disabling SIP (which I can’t do anymore as a result of I want some iOS apps on my Mac which solely work with SIP enabled).

I’ve since settled on utilizing a single House with my rcmd app for fast app switching.


I wished to enhance this expertise although, so I made animations utterly configurable in Grila.

As a result of they’re computationally costly, I disabled them by default in Low Energy Mode, or when Scale back Movement is enabled in Accessibility settings.

// in AppDelegate
reduceMotionObserver = NotificationCenter.default
    .writer(for: NSWorkspace.accessibilityDisplayOptionsDidChangeNotification)
    .sink { _ in reduceMotion = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion }

lowPowerModeObserver = NotificationCenter.default
    .writer(for: .NSProcessInfoPowerStateDidChange)
    .sink { _ in lowPowerMode = ProcessInfo.processInfo.isLowPowerModeEnabled }


// in SwiftUI
withAnimation((reduceMotion || lowPowerMode) ? .linear(length: 0) : .spring()) {
    actionProducingLayoutChange()
}

I additionally gave the consumer the selection to allow them again, disable them utterly and even change their velocity and springiness.

Grila animation settings

# Is it quick but?

Two months later, after exhausting all of the optimization prospects, the app lastly felt usable.
It was lots quick for the actions that had been most frequently used so I lastly launched it.

However it may very well be higher.

I’m not solely certain if there’s nonetheless plenty of efficiency left on the desk for the SwiftUI engineers, but it surely kinda appears to be like that means from my facet. There are some locations the place the framework appears to be doing plenty of pointless work.

Taking the 3-months view for instance, it renders 11 rows of days, of which solely half change when going to the following month. However I’m nonetheless seeing all 11 x 7 views being scrapped and rendered from scratch.

Within the AppKit world I might have had the day views as class as an alternative of struct, and I may have up to date their day quantity and weekday in place. It wouldn’t be straightforward, but it surely’s an optimization that will have made all of the distinction.

The yr view is an much more dramatic instance, the place the 365 day views are made up of solely 31 stylised textual content views, cloned and positioned on totally different coordinates. These 31 views may even be drawn beforehand and cached perpetually. I discovered no means of letting SwiftUI learn about this reality, and have it used for optimization.

# Is there hope?

Clearly, I don’t know of the underlying structure of SwiftUI, so what I am saying won’t even be doable. Or, when carried out, it could open up a can of edge instances and inflicting bugs that will be even worse than not having it within the first place. Or possibly my particular use case is attributable to my very own lack of ability to make use of SwiftUI correctly.

Possibly I am simply not holding it proper.

However ultimately, different app devs will attempt the identical issues I attempted. The chaos of this world ensures it.

And it is not a giant deal, folks may have lived with out yet one more calendar app. I may do different issues to assist my brother. The solar would nonetheless shine the following day (till it implodes 5 billion years sooner or later).

However ever since leaving the AppKit and UIKit world, I felt that these Apple engineers have created a magical device. I felt like going from hammering a chunk of metallic to a CNC precision cutter. I am utilizing much less power, transferring a lot sooner, and issues find yourself even nearer to how I wished them to look.

Within the final 3 years, I had the sensation that the final word purpose for SwiftUI is to achieve characteristic parity with the opposite established system UI frameworks, and we’re extremely near that.

My hope is that after this, these Apple engineers will realise what an excellent factor they created, and proceed honing it till we’ll now not have the necessity to evaluate the brand new with the outdated.

Source Link

What's Your Reaction?
Excited
0
Happy
0
In Love
0
Not Sure
0
Silly
0
View Comments (0)

Leave a Reply

Your email address will not be published.

2022 Blinking Robots.
WordPress by Doejo

Scroll To Top