• Autonomous Standing Desk and Chair Review

    November 14, 2023

    Black cat sitting on red chair facing camera in front of white standing desk

    Autonomous was nice enough to send me one of both their Smart Desk Pro standing desks and ErgoChair Pro chairs in exchange for posting about them on Twitter, and I wanted to cover them in more detail on my blog as well so I could give my full thoughts on them for anyone in the market for a standing desk and chair. I care a lot about a quality, ergonomic desk set up and have tried a lot of different products, so I like to think I have a decent perspective here.

    The tl;dr is that they’re both really nice, though they have a few small things I would personally tweak.

    Small note: I’m Canadian but I’m putting all the prices in US dollars since most of my readers are American.

    The Desk

    Black cat walking across white standing desk top in high desk position

    I’m a programmer and care a lot about the place that I sit and stand at for an inordinate amount of time, so over the years I’ve tried a lot of different products to make my setup more comfortable, inviting, and just more pleasurable to use. That includes a few standing desks.

    My first standing desk was the cheapest one I could find on Amazon that had memory presents (trust me, you really want to spend the extra twenty bucks or whatever to not have to hold a button down for awhile and hope you hit the approximate height you like).

    I would not recommend this approach. It had a single motor in one of the legs that then twisted a rod that moved the other leg up. This results in a cheaper desk, but a much less reliable one, and I pressed the button one day to find my desk surface at a 45° angle when the motor had an issue getting the other side to cooperate. The desk had a comically short warranty so I was just out of luck and instead of just originally spending a bit more for a quality desk, I was now just doing that anyway but out the cost of an additional desk.

    I ended up buying a Jarvis standing desk by Fully after being recommended by a friend, and it’s been great. Both that desk and the Autonomous one are very similar and have dual motors with one per leg. At the time of writing, both have Black Friday sales but the Autonomous desk comes in a fair bit cheaper at $489.00 (with my code “22BFSELIG”) for the Autonomous and $599 for the Jarvis.

    I can’t speak to the Jarvis desktops (I bought mine just as the frame itself, as at the time shipping to Canada was very expensive), but I like the white laminate one from Autonomous a lot. I originally thought it would be one of those IKEA style ones that are filled with cardboard, but this sucker is heavy and has nice grommets to route your wires through. Funnily enough those I visually prefer the square corners of the IKEA cardboard edition versus the rounded Autonomous ones, so I ended up using it with the IKEA top.

    Autonomous also offers an even cheaper “SmartDesk Core” but it does not offer the height adjustability that the Pro offers so that model was a non-starter for me. In order to have an ergonomic wrist angle with my keyboard I like to put the desk quite low, so make sure you have an idea of where your ideal desk height is and that the desk you buy supports that. I was kinda surprised to see that the Autonomous desk, despite being listed as having a higher minimum height than the Jarvis, actually beats the Jarvis. At the lowest height, the Autonomous desk is 25" off the ground, while the Jarvis sits a quarter inch higher at 25.25".

    Measuring tape showing the Autonomous desk at 25 inches tall at its lowest position

    The Autonomous desk is also noticeably quieter than the Jarvis when in operation. Not to say the Jarvis is loud per se, but the Autonomous is a fair bit less noticeable when going up and down. My initial reaction was that maybe the Autonomous has weaker motors, but if I sit on both desks, both are still easily able to go up and down, and I weight 180 lbs, and I’m not sure anyone’s every day desk has more weight than that on it. They also go up and down at pretty much the exact same speed as far as I can tell.

    One area where I will give it to the Jarvis though is I slightly prefer their control system. I imagine it’s to prevent accidental input, but the Autonomous requires you hold down the buttons for a beat before they engage and start moving the desk, while the Jarvis is as soon as you touch it. This is probably a matter of preference, but it also manifests when you’re manually moving the desk with the up and down arrows, and when you reach your setting and release your finger, the Autonomous takes a second before it stops so unless you account for that you typically overshoot your target slightly. I’d love to see a dip switch or something on the controller that would allow you to control this functionality.

    Overall, especially with the price advantage, I’d go with the Autonomous desk personally. Pleased as punch, and now I have an extra desk set up in the corner of my office in case I… want to change it up I suppose? Now I finally have a use for that LG Ultrafine 5K that’s been sitting in my closet for two years!

    Oh, and get a standing desk mat. This is not negotiable, there’s a reason grocery store employees all use them. Standing for hours on a hard surface is not great for you, and will catch up to you eventually. It will make the standing desk a million times more inviting and comfortable to use, and they’re pretty inexpensive.

    The Chair

    Black cat sitting on red office chair in front of desk staring deeply into camera

    Standing desks always seem like a slam dunk, “duh” upgrade to people, but chairs seem unfortunately underappreciated. But they shouldn’t be! All the time you’re not standing, you’re sitting, and doing so in a good quality chair will pay dividends in the health of your body over the years.

    So naturally, I’ve tried quite a few desk chairs over the years. About seven years ago I went on a quest to get a good quality chair, and tried out all the “greats” of that era. I rented a Herman Miller Embody, a Herman Miller Aeron, a Steelcase Gesture, and a Steelcase Leap. While they were all quality chairs, the Steelcase Leap ended up being my favorite by a fair bit.

    Chairs are super personal, and the Leap just seemed to meld with my body the best, so I encourage you to try out chairs in person if possible, or order from a company that allows returns if you’re not satisfied. Seriously, the Herman Miller Embody from what people said sounded like it descended from the heavens, but I just wasn’t a big fan of how it fit against my back despite attempted adjustments. Autonomous seems to fit the bill for allowing to send back if you’re not a fan, though if buying during a sale like Black Friday I would contact them to see if that still applies, as they seem to have an asterisks for sale items.

    So basically, take the time to learn the adjustments on the chair, watch a YouTube video or two on proper ergonomics, and adjust the chair to fit you. Just through sheer combinations, the configuration that a chair comes in out of the box is likely not the one that best suits you!

    Long story short, the Autonomous ErgoChair Pro (they also have an ErgoChair Plus that seems closer to the Herman Miller Embody style) is a really comfy chair, and I love the red color I ordered it in. Also, between my Leap, my girlfriend’s office chair, and the Autonomous chair, my cat Ruby always chooses the Autonomous to sleep on, so that must mean something (I think it’s the fact it has the widest butt cushion area, which makes it feel a bit like you’re sitting on a throne).

    The price difference is pretty substantial too, at the time of writing the ErgoChair Pro is well under half the price of the Steelcase Leap (again, use that 22BFSELIG code to grab an additional 10% off for Black Friday).

    Black cat lying on red office chair staring at camera from above with a relaxed expression
    She won’t let me sit :(

    I’ve been using the Autonomous chair for about a week now at my desk, and while I think it’s a quality chair, I think I still have a slight preference for my Leap. This shouldn’t be super surprising at over double the price, but there’s a few things that push it slightly in the favor of the Leap for me.

    For one, the Autonomous chair is very adjustable, but I can’t quite get the lumbar support to a place I like versus the Leap. It’s just slightly more dramatically pushed in on the Autonomous versus the Steelcase, and that is adjustable, but I can’t dial it back enough (the Leap’s lumbar is a lot more adjustable).

    I also like on the Leap how when you recline, the seat cushion automatically slides forward a bit with you, so you don’t like slump off the chair as much. I also like that you can choose how far it can recline, so you can allow for a bit of a recline rather than going like super far back. Lastly, the height of the arms on the Leap can go lower than the Autonomous, which is just enough that I can push the Leap under my desk but not the Autonomous. You could always just not install the arms on the Autonomous though if you don’t use them.

    All that said, my girlfriend has your generic $70 Staples chair and I gave her the Autonomous to try for a bit, and she really, really liked it. Again, it’s more expensive, so not super surprising, but a quality chair is important. She also really liked that the Autonomous chair has a headrest versus my Steelcase one (you can buy one for the Steelcase, but it costs extra and isn’t nearly as customizable as the Autonomous one) and is now happily using it. See what I mean about personal preference?

    I really wish my Leap had the Autonomous’ adjustment for the angle of the seat, though. And I have to admit the Autonomous looks a fair bit cooler than the Steelcase Leap which kinda just looks like your run of the mill corporate America office chair.

    Overall

    I don’t get sent a lot of free stuff despite loving free stuff, so I was somewhat scared these were going to arrive and I might have to contact Autonomous and be like “ehhhhh, thanks for sending but not a fan”, but I’m delighted that they’re both very nice, price competitive options in the ergonomic desk setup space that I have no issue recommending. And if you do end up going with Autonomous, Black Friday is a great opportunity, and be sure to take 10% off on top of the existing sales with my coupon: 22BFSELIG. (I don’t get any kickback, but you might as well save some extra money! :p)

    Seriously, be it these options or something entirely different, do yourself a favor (if you have the means) and treat yourself to a quality desk and chair (and ideally keyboard, mouse, and monitor height). They can make a big difference in your health, especially compounded over years and years and years of heavy use if you’re in a desk-heavy job like programming, design, customer support, etc.

    There’s always that adage about treating yourself to quality things if they separate you from the ground, which is normally said in the context of shoes and a mattress, but given that many of us spend as much time at our desk as we do sleeping, I think a quality desk setup easily qualifies as well.


  • Smart Open Xcode

    August 2, 2023

    Multiple versions of Xcode in the macOS Command Tab switcher

    If you’re like me, you often have multiple versions of Xcode installed. One or two beta versions, a stable version, and maybe another version in case the most recent stable version has something weird about it.

    I also really like mapping my Caps Lock key to something more useful, and after reading Brett Terspstra’s excellent article on making a Hyper key many years ago, I’ve gotten used to hitting Caps Lock + X to jump to Xcode thanks to Karabiner Elements and Alfred. It works a lot like Command + Tab, but doesn’t require hitting Tab until you find it. It basically maps Caps Lock to holding down Command, Option, Control, and Shift all at once, a modifier that is very unlikely to conflict with anything else.

    Anyway, this setup works by mapping a hotkey to a specific app, which works great 99% of the time, but if you’re working in a beta version of Xcode, and you have that keyboard shortcut mapped to the stable version, it opens the wrong app and can even sometimes get Xcode confused about accessing a file in multiple locations. Not good!

    So, we need a smarter version rather than just hardcoding the app. Lots of options exist, like AppleScript (had a small delay though) and Keyboard Maestro, probably Shortcuts too, but I like a little Lua scripting utility called Hammerspoon. Just download it and drop it in your Applications folder.

    At that point, we can add a short script to our init.lua file that uses Hammerspoon’s find() API to, well, find the version of Xcode that’s currently running, and open that, rather than a hardcoded one. Nice! (If you keep multiple versions of Xcode open at once, I don’t know what to tell you, weirdo.)

    hs.hotkey.bind({"cmd", "alt", "ctrl", "shift"}, "X", function()
        -- Use the bundle ID rather than just 'Xcode' to prevent it from trying to open utility apps like 'Xcodes'
        local xcode = hs.application.find("com.apple.dt.Xcode")
        
        -- If nothing is found then just alert the user to open one, it would be very hard to guess which one they want if none are open!
        if xcode == nil then 
            hs.notify.new({title="Xcode is not open! 🫨", informativeText="Manually launch an Xcode instance and then I’ll be able to work!"}):send()
            return
        end
    
        --- If not the frontmost app, make it the frontmost! If it already is, hide it (makes it more toggle-y).
        if xcode:isFrontmost() then
            xcode:hide()
        else
            xcode:activate()
        end
    end)
    

    Voilà! Works like a charm. Here’s an addition that you can add as well that extends it to Simulators:

    hs.hotkey.bind({"cmd", "alt", "ctrl", "shift"}, "A", function()
        -- Use the bundle ID rather than just 'Simulator' to prevent it from trying to open random background processes, and yes 'iphonesimulator' works for iPads as well
        local simulator = hs.application.find("com.apple.iphonesimulator")
        
        -- If nothing is found then just alert the user to open one, it would be very hard to guess which one they want if none are open!
        if simulator == nil then 
            hs.notify.new({title="Simulator is not open! 🫨", informativeText="Run your project in Xcode to launch it in a simulator, then I'll know what to open!"}):send()
            return
        end
    
        --- If not the frontmost app, make it the frontmost! If it already is, hide it (makes it more toggle-y).
        if simulator:isFrontmost() then
            simulator:hide()
        else
            simulator:activate()
        end
    end)
    

  • Instant Pan Gesture Interactions

    May 13, 2023

    Apple has a really awesome WWDC 2018 video called Designing Fluid Interfaces, and one of the key takeaways from the videos that one of the presenters, Chan Karunamuni, said is “Look for delays everywhere. Everything needs to respond instantly.” (6:28)

    A really great example of this is a scroll view on iOS. If you flick through your contacts and touch your finger to the screen, instantly the scroll view stops and let’s you reposition it. This kind of instantaneous behavior is really important in our own views, interactions, and animations.

    Okay, so I was building a view where I wanted this behavior. Basically, you flick a box from the left side of the screen to the right, and as you release your finger it keeps going. Of note though is that you should be able to grab the box while it’s in flight to stop it.

    Turns out this is trickier than it seems, as UIPanGestureRecognizer has a small delay in startup where it requires you to move your finger before it recognizes the gesture starting. If you just touch your finger on the moving object, that’s not technically a “pan”, so it ignores you (makes sense), which means the object just keeps moving until you move enough to trigger a “pan”, the result of this is an interaction that doesn’t feel very responsive.

    First attempt at a solution

    One solution that works in many cases for many people is to implement an “InstantPanGestureRecognizer”, where unlike the normal one, it enters its “began” state as soon as a finger is touched down, allowing you to respond instantly to user input and pause the animation as soon as their finger touches the screen.

    Nathan Gitter has an awesome article showing code examples of many of the interactions shown in Apple’s video. It’s an amazing article. He has a section on implementing this custom pan gesture:

    class InstantPanGestureRecognizer: UIPanGestureRecognizer {
        override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
            super.touchesBegan(touches, with: event)
            self.state = .began
        }
    }
    

    For awhile this worked, but something changed in a recent version of iOS that effectively broke it. On iPhones it mostly works fine, but on iPads, and on iPhones if the view is near the home indicator at the bottom, there’s a delay in between the state being updated to .began, and the pan gesture handler being notified of the state change. It’s about 0.75 seconds, which is an enormous amount of time for a gesture (on a 120 Hz device, that’s 90 frames rendered before your input is recognized!).

    Based on console logs and inspecting the UIGestureRecognizers attached to the app’s UIWindow, it looks like there’s something called a “system gesture gate”, that I’m assuming gates off gestures from conflicting with system ones (particularly on the iPad, which has lots of multitasking gestures), which means ones attached to the app run at a lower priority and could be delayed.

    (Note that the preferredScreenEdgesDeferringSystemGestures property does not seem to have an effect here.)

    That being said, since the state does update instantly, it’s just the communication of the state change that’s delayed, it’s pretty easy to work around this and still have an InstantPanGestureRecognizer. Add the code for the custom gesture above, but instead of using addTarget(...) to listen for changes to the gesture recognizer, which has a delay in some cases, just jump straight to KVO:

    static var gestureContext = 1
        
    override func viewDidLoad() {
        super.viewDidLoad()
        
        let panGestureRecognizer = InstantPanGestureRecognizer()
        panGestureRecognizer.addObserver(self, forKeyPath: #keyPath(UIPanGestureRecognizer.state), options: .new, context: &Self.gestureContext)
        view.addGestureRecognizer(panGestureRecognizer)
    }
    
    override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
        guard context == &Self.gestureContext else {
            // Be sure to pass on any observes that we didn't explicitly ask for!
            super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
            return
        }
        
        guard let panGestureRecognizer = object as? UIPanGestureRecognizer else { return }
        
        switch panGestureRecognizer.state {
        case .began:
            // This is no longer delayed! Stop animation if has not already ended
        case .changed:
            // Update position of object
        case .ended:
            // Continue object's momentum with animation
        }
    }
    

    The .began state will now trigger without needing to move your finger first, and without delay on iPads.

    But I think there’s an even better solution for some cases, read on!

    A solution I like more

    While that previous gesture setup works great, especially in the case Nathan Gitter outlined in the aforementioned tutorial, I think there’s one catch that could trip you up in some situations: pan gesture recognizers have a movement delay before starting for a reason. This small delay can be important, because it allows us to have some information about the pan by the time it starts, like which direction the user is moving in.

    This movement data can be super handy, for, say, only making the gesture start if it’s moving horizontally. For example, say we have a box inside a scroll view, and we want to let the user pan the box from left to right, but still allow the scroll view to scroll up and down, we could look at our object’s pan gesture and only allow it to start if the movement is horizontal. (If the pan gesture were to start as soon as the user’s finger touches the screen, we won’t be able to know which direction they’re panning in to stop it.)

    So while the small delay introduced in a recent OS version on iPads is unfortunate, it gives us an opportunity to look outside of an instant pan gesture recognizer and its limitations.

    What would that be? Something that would allow the pan gesture recognizer to operate normally, but have a secondary system that allows us to know as soon as the user touches down on the screen.

    There’s a few ways to do this, but since we’re describing an interaction, I think an additional gesture recognizer would work perfectly here. UITapGestureRecognizer immediately comes to mind, but requires the user’s finger to lift up afterward, which rules it out for us (we want to be able to just touch down to pause, then pan around if desired). UILongPressGestureRecognizer could actually work here if we set minimumPressDuration to a super small number, but that feels kinda hacky (it’s not really a long press at that point, is it)?

    Okay, so let’s build our own. It’s actually really simple:

    class TouchesBeganGestureRecognizer: UIGestureRecognizer {
        override func canPrevent(_ preventedGestureRecognizer: UIGestureRecognizer) -> Bool {
            return false
        }
        
        override func canBePrevented(by preventingGestureRecognizer: UIGestureRecognizer) -> Bool {
            return false
        }
        
        override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
            super.touchesBegan(touches, with: event)
            
            state = .began
        }
        
        override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
            super.touchesMoved(touches, with: event)
            
            state = .ended
        }
        
        override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent) {
            super.touchesMoved(touches, with: event)
            
            state = .cancelled
        }
        
        override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) {
            super.touchesEnded(touches, with: event)
            
            state = .ended
        }
    }
    

    We create a custom gesture recognizer that both can’t be prevented by other gesture recognizers, and can’t prevent others (this means it works on top of other gesture recognizers). And as far as states goes, it enters the .began state as soon as the user touches down, but as soon as anything else happens, it ends (there’s no .changed state).

    Then we can take this new gesture and add it in addition to our pan gesture. Here’s a simple example showing this, where we use a pan gesture to move a box, when the pan gesture ends the box continues moving in the direction the user threw it, and they can then instantly grab it while it’s in flight to stop it and/or start moving it around again. Note that for sake of keeping the example short, the direction is only left-to-right, and the gesture’s velocity is not calculated into the animation (for the latter Nathan’s article above goes over conserving momentum as well if you’re curious!).

    class ViewController: UIViewController {
        let box = UIView(frame: CGRect(x: 0.0, y: 200.0, width: 200.0, height: 200.0))
        var propertyAnimator: UIViewPropertyAnimator?
    
        override func viewDidLoad() {
            super.viewDidLoad()
            
            box.backgroundColor = .systemOrange
            view.addSubview(box)
            
            let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(panGestureUpdated(panGestureRecognizer:)))
            box.addGestureRecognizer(panGestureRecognizer)
            
            let touchesBeganGestureRecognizer = TouchesBeganGestureRecognizer(target: self, action: #selector(touchesBeganGestureRecognizerUpdated(touchesBeganGestureRecognizer:)))
            box.addGestureRecognizer(touchesBeganGestureRecognizer)
        }
        
        @objc private func panGestureUpdated(panGestureRecognizer: UIPanGestureRecognizer) {
            if panGestureRecognizer.state == .began {
                // Normalize the gesture's starting translation with where the box is at the start
                panGestureRecognizer.setTranslation(CGPoint(x: box.frame.origin.x, y: 0.0), in: box)
            } else if panGestureRecognizer.state == .changed {
                box.frame.origin.x = panGestureRecognizer.translation(in: box).x
            } else if panGestureRecognizer.state == .ended {
                let propertyAnimator = UIViewPropertyAnimator(duration: 1.5, curve: .linear)
                self.propertyAnimator = propertyAnimator
                
                propertyAnimator.addAnimations {
                    self.box.frame.origin.x = self.view.bounds.width - self.box.bounds.width
                }
                
                propertyAnimator.addCompletion { position in
                    self.propertyAnimator = nil
                }
                
                propertyAnimator.startAnimation()
            }
        }
        
        @objc private func touchesBeganGestureRecognizerUpdated(touchesBeganGestureRecognizer: TouchesBeganGestureRecognizer) {
            guard touchesBeganGestureRecognizer.state == .began else { return }
            guard let propertyAnimator else { return }
            
            propertyAnimator.stopAnimation(true)
            
            self.propertyAnimator = nil
        }
    }
    

    There you go! Hopefully that gives you a nice way of making a pan gesture animation feel even more responsive.


  • First on New Blog

    January 28, 2023

    Converted the blog over to a new Hugo theme! Hopefully everything here sorta works. Test post will remove.

    A very cute small goat

    This goat’s name is apparently Gubgub, and very cute.


  • Theming Apps on iOS is Hard

    February 7, 2022

    Theming apps (the ability to change up the color scheme for an app from say, a white background with blue links to a light green background with green links) is a pretty common feature across a lot of apps. It’s one of the core features of the new “Twitter Blue” subscription, Tweetbot and Twitterific have had it for awhile, my app Apollo has it (and a significant subset of users use it), and it’s basic table stakes in text editors. When you use the heck out of an app, it’s pretty nice to be able to tweak it in a way that suits you more.

    Three examples of different dark themes in the Apollo app

    For the longest time, by default, an app had one color scheme. Dark mode didn’t exist at the iOS level, so it was up to apps to have two sets of colors to swap between individually. With iOS 12 Apple made that a lot nicer, and made switching between a light color scheme and a dark color scheme really easy.

    The Current Light/Dark Mode System

    The current system is great for switching between light mode and dark mode. Each “color” basically has two colors: a light mode version and a dark mode version, and instead of calling it “whiteColor”, the color might be called “backgroundColor”, and have a lightish color for light mode and a darker color for dark mode. You set that on whatever you’re theming, and bam, iOS handles the rest, automatically switching when the iOS system theme changes. Heck, Apple even defines a bunch of built in ones, like “label” and “secondaryLabel”, so you likely don’t even have to define your own colors.

    The code defining, say, a custom blue accent/tint color for your app looks basically like:

    if lightMode {
        // A rich blue
        return UIColor(hexcode: "007aff") 
    } else {
        // A little brighter blue to show up on dark backgrounds
        return UIColor(hexcode: "4BA1FF") 
    }
    

    (For a thorough explanation of this system, NSHipster has a great article.)

    The Problem

    This quickly falls apart when you introduce theming. Maybe blue is a safe bet as your app’s “button color” for 95% of users, but a subset are going to want to make that more personal. Maybe a mint color? A pink! If we’ve learned anything through the craze of app’s like Widgetsmith, people love to make things their own.

    But wait, how do we do this when the system is built around only having two options: one for light mode, and one for dark? We might want to have a “Mint” theme, with a delightful green tint instead.

    Perhaps something like this?

    if lightMode {
        if mintSelected {
            // Minty!
            return UIColor(hexcode: "26C472")
        } else {
            // A rich blue
            return UIColor(hexcode: "007aff")     
        }
    } else {
        if mintSelected {
            // Dark mode minty!
            return UIColor(hexcode: "84FFBF")
        } else {
            // A little brighter blue to show up on dark backgrounds
            return UIColor(hexcode: "4BA1FF") 
        }
    }
    

    Beautiful! This actually works super well, if we start up our app, iOS will see that mint is selected and choose the mint colors instead.

    However, there’s a serious catch. If the app started up in normal (AKA non-minty) mode, and the user selects the mint theme at some point, iOS kinda looks the other way and ignores the change, sticking with blue instead. The conversation kinda goes like:

    Me: Hey iOS! The theme is minty now, blue is so last season. Can you update those buttons to mint-colored?

    iOS: Well, I asked earlier and you said blue. No take backs. The paint is dry, no updates allowed.

    Me: But if the user changes the device theme to dark mode, you’ll happily update the colors! Could you just do that same thing now for me?

    iOS: Hard pass.

    Me: But the header file for hasDifferentColorAppearanceComparedToTraitCollection even says changes in certain traits could affect the dynamic colors, could you just wrap what those changes call into a general function?

    iOS: I said no take backs! But let’s together hope one of the awesome folks who works on me adds that in my next major version!

    So what do you do? Have the user force-quit the app and relaunch every time they want to change the theme? That’s not very Apple-y. Reinitialize the app’s view hierarchy? That can mess with lots of things like active keyboards.

    Let’s Go Back in Time

    Remember how I said in the pre-iOS 12 days, where iOS didn’t even had a dark mode, developers had to get a bit more inventive? Apollo’s theming system was actually written way back then, so I’m pretty familiar with it! Basically how it works is you don’t talk to iOS like above, instead you talk to each view on screen directly. Cut out the middleman!

    Leveraging something like NSNotificationCenter (or a more type-safe version via NSHashTable with weak object references) you’d basically go to each view you wanted to color, and say “Hey, you’re blue now, but why don’t you give me your phone number so if anything changes I’ll let you know?” and you’d register that view. Then when the user asked to go to dark mode, you’d quickly phone up all the views in the app and say “Change! Now! Green!” and they would all do that.

    The beauty is that when you “phone them up”, you can tell them any color under the sun! You have full control!

    Here’s a quick example of what this might look like, somewhat based on how I do it in Apollo:

    protocol Themeable: AnyObject {
        func applyTheme(theme: Theme)
    }
    
    enum ColorScheme {
        case `default`, pumpkin
    }
    
    struct Theme {
        let isLightModeActive: Bool
        let colorScheme: ColorScheme
    
        var backgroundColor: UIColor {
            switch colorScheme {
                case .default:
                    return isForLightMode ? UIColor(hexcode: "ffffff") : UIColor(hexcode: "000000")
                case .pumpkin:
                    return isForLightMode ? UIColor(hexcode: "ff6700") : UIColor(hexcode: "733105")
            }
        } 
    
        // Add more colors for things like tintColor, textColor, separators, inactive states, etc.
    }
    
    class ThemeManager: NSObject {
        static let shared = ThemeManager()
    
        var currentTheme: Theme = // initialize value from UserDefaults or something similar
    
        private var listeners = NSHashTable<AnyObject>.weakObjects()
    
        // This would be called by an external event, such as iOS changing or the user selecting a new theme
        func themeChangeDidOccur(toTheme newTheme: Theme) {
            currentTheme = newTheme
            refreshListeners()
        }
    
        func makeThemeable(_ object: Themeable) {
            listeners.add(object)
            object.applyTheme(theme: currentTheme)
        }
    
        private func refreshListeners() {
            listenersAllObjects
                .compactMap { $0 as? Themeable }
                .forEach { $0.applyTheme(theme: currentTheme) }
        }
    }
    
    // Do this in every view controller/view:
    class IceCreamViewController: UIViewController, Themeable {
        let leftBarButtonItem = UIBarButtonItem(title: "Accounts")
    
        override func viewDidLoad() {
            super.viewDidLoad()
        
            ThemeManager.shared.makeThemeable(self)
        }
    
        func applyTheme(theme: Theme) {
            // e.g.:
            leftBarButtonItem.tintColor = theme.tintColor
        }
    }
    

    So this works but has a lot of downsides. For one, it’s a lot harder. Rather than just setting view.textColor = appTextColor in a single call and have it automatically switch between light and dark mode colors that you defined as needed, you have to set the color, register the view, have a separate theming function, and then go back and talk to that view whenever anything changes. A lot more arduous in comparison.

    There’s other aspects to consider as well. Because iOS is smart, when an app goes into the background, iOS quickly takes a screenshot of the app to show up in the app switcher, but it also quickly toggles the app to the opposite theme (so dark mode if the system is in light mode) and takes a screenshot of that as well, so if the system theme changes iOS can instantly update the screenshot in the app switcher.

    The result of this is that iOS rapidly asks your app to change its theme twice in a row (to the opposite theme, and then back to the normal), if you don’t do this quickly, you’re in trouble. Indeed, it’s one of my top crashers as of iOS 15, and I assume it’s because I use this old method of talking to every single view to update, and iOS uses a more efficient method under the hood.

    You also hit speed bumps you don’t really think of when you start out. For instance, say parts of your app support Markdown rendering where links embedded in a block of text reflect a specific theme’s tint color. When the theme changes, with this system you get that notification, and what do you do? Recompute the NSAttributedString each time you get a theme change? Perhaps only do it the first time, cache the result, and then on theme change iterate over that specific attribute and update only those attributes to the new color. You know what’s a lot nicer than all that rigamarole each time? Just setting the dynamic color in your Markdown renderer/attributed string once, and having iOS handle all the color changes like in the newer solution.

    So as you may have guessed I’ve been meaning to update my old system to this newer one. (Wonder why I was writing this blog post?)

    (For a thorough writeup on this kind of system, the SoundCloud Developer Blog has a great article, and Joe Fabisevich also has a really cool variation based on Combine.)

    SwiftUI

    SwiftUI is new and really exciting, and something I’m looking forward to using more in my app. The tricky thing with this antiquated solution is it doesn’t work too well with SwiftUI, subscribing everything into NotificationCenter calls and callbacks isn’t exactly very SwiftUI-esque and ruins a lot of the elegance of creating views in SwiftUI and at best adds a lot of boilerplate.

    So if the old system isn’t great, what about the newer, post-iOS 12 dynamic color one? While SwiftUI has its own Color object which unlike UIColor lacks support for custom dynamic colors (I believe) you can initialize a Color object with a UIColor and SwiftUI will dynamically update when light/dark mode changes occur, just like UIKit! Which makes the “newer” solution a lot nicer as it works well in both “worlds”.

    What Would be the Perfect Solution from Apple?

    The perfect solution would be Apple simply having a method like UIApplication.shared.refreshUserInterfaceStyle() that performs the same thing that occurs when iOS switches from light mode to dark mode. In that situation, there’s a code path/method on iOS that says “Hey app, update all your colors, things have changed”, and simply making it so app developers could call that on their own app would make everything perfect. Theme changes would redraw as requested, no having to force-quit or talk to each and every view manually, and it would work nicely with SwiftUI! (Apple folks: FB9887856)

    Hacksville

    In the absence of that method (fingers crossed for iOS 16!), can we make our own method that accomplishes effectively the same thing? An app color refresh? Well, there’s a couple ways!

    • Martin Rechsteiner mentioned a clever way on Twitter, wherein you change the app’s displayed color gamut. Since the color profile of the entire app is changing, iOS will indeed update all the colors. The downside is, well, you’re changing the app’s color gamut from say, P3 to SRGB, which can presumably have some effects on how colors look. It shouldn’t be super obvious, since from what I can tell UIImageViews and whatnot have their embedded color profiles separate from app, so pictures and whatnot should still display correctly. But it’s still suboptimal. You could always immediately switch back to the previous color gamut after, but that has the problems of solution 2.
    • If you’re in light mode set overrideUserInterfaceStyle to dark mode on the app’s UIWindow, and then change it back (or vice-versa). The downside here, is that if you do it in the same pass of the runloop, colors will update but traitCollectionDidChange does not fire in the relevant view controllers which may be important for things like CALayer updates. You can dispatch it to the next loop with good ol’ DispatchQueue.main.async { ... } on the second call, but then traitCollectionDidChange will be called twice, and unless you do a bit more work the screen will have a quick flash as it jumps between light and dark mode very quickly.

    Of the two, I think I prefer the second solution slightly. Even though it calls the method twice, and flashes a bit, you can negate the flash by putting a view overtop the main window (say, a snapshot from immediately before that pleasantly fades to the new theme) and the traitCollectionDidChange being called twice likely isn’t much concern.

    Put Those Two Together? PB & J Sandwich?

    Another solution would be to take parts of both systems that work and put them together: use dynamic colors for 97% of the heavy lifting, but when a color has to change immediately in response to a user changing themes, then you use the “notify all the views in the app manually” method. This would likely be fine when going into the background and snapshotting, because that would use dynamic colors, and the “notifying all the views” would only occur when the app is in the foreground with the user manually changing the theme.

    Still, I don’t really like that we have to have a separate system maintained where we have to keep track of every view in the app that might need a color change, for the 3% of the time the user might change the theme. That’s a lot of boilerplate and excess code for something that could simply be handled by a refresh method on UIApplication. (And yes, you could say “if it’s that rare, just have them force quit the app or something else gross”, but you want the user to be able to quickly preview different themes without a ton of friction in between.)

    So all in all, I think I’m going to go with the overrideUserInterfaceStyle kinda hack, and hope iOS 16 sees a proper, built-in way to refresh the app’s colors. But if you have a better solution I’m all ears, hit me up on Twitter!