iCalvin

Experimenting With Windowing on iPad With PanelKit

I’m typically pretty reluctant to lean on third party libraries in my code, especially when it wraps core UI interaction models, because they can make it even more difficult to prepare for new updates during the post-WWDC period of the summer. However, the first of a week long series of scoops at 9to5Mac called out a feature that has been long rumored for iOS, the ability take popover-like panels and move them around freely on screen in windows.

Explicitly, Gui Rambo called out that the feature that would ship with iOS 13 would be similar to the PanelKit project, which was archived a year ago when the creator got hired at Apple. Well that was enough for me to take a closer look at PanelKit and get a sense for what it could do, and what sort of considerations would be needed when we eventually move to a windowed paradigm on iOS.

I spent some time on CastKit this week talking about this project and I’m really excited to share in a bit more detail here. I maintain an app to post to this blog as well as my social accounts, and I’d been meaning to update that app anyway.

When I designed the current version of the app during the iOS 11 beta period I just went the easy route and put everything in a tableView, which worked out fine enough, but I didn’t give myself quite enough space in the Body field, instead opting for a focused BodyDetailViewController. The thinking was that it would be enough for a quick tweet and the detail was for anything more, but in practice that extra UI was filling up the screen and I used the detail view much more often than I would have liked.

Main Compose/Edit and Body Detail Views

But by creating ViewControllers for all of these PostInfoComponents and conforming them to PanelContentDelegate we can give PanelKit enough to present these view controllers in resizable, draggable windows.


extension PostTitleViewController: PanelContentDelegate {
    var preferredPanelContentSize: CGSize {
        return CGSize(width: 400, height: 150) }
    var minimumPanelContentSize: CGSize {
        return CGSize(width: 250, height: 100) }
    var maximumPanelContentSize: CGSize {
        return CGSize(width: 1000, height: 500) }

    var preferredPanelPinnedWidth: CGFloat {
        return 400 }
    var preferredPanelPinnedHeight: CGFloat {
        return 150 }

    var shouldAdjustForKeyboard: Bool {
        return true }
}

Main Compose/Edit and Info Panels

This viewController gets passed to a PanelViewController initializer, which also takes in a PanelManager. This manager is a protocol that deals with mainting the panels size, location, and ‘pinning’ state.

Once the PanelViewController is created I make sure to put it in the [Panel] array of the PanelManager (If your panel gets removed from this array while on screen, it will stop responding to drag or resizing events), and give that view controller to the PostContentViewController to present as a popover. The PostTitleViewController will be embedded within a navigation controller in the PanelViewController with a bar button to float the panel.


// PanelManager
private func addPanel(_ panel: PanelViewController) {
    panels = panels.filter({
        $0.contentViewController?.view.superview != nil })
    styleNavBar(panel.panelNavigationController.navigationBar)
    panels.append(panel)
}
func createTitlePanel() -> PanelViewController? {
    guard let titleViewController = createTitleViewController()
        else { return nil }
    let titlePanel = PanelViewController(with: titleViewController,
                                         in: self)
    addPanel(titlePanel)
    return titlePanel
}
// PostContentViewController
@IBAction func titleButtonTapped(_ sender: Any) {
    guard let titlePanel = panelManager?.createTitlePanel(),
        let button = sender as? UIBarButtonItem
        else { return }
    titlePanel.modalPresentationStyle = .popover
    titlePanel.popoverPresentationController?.barButtonItem = button
    present(titlePanel,
            animated: true,
            completion: nil)
}

One of the requirements of the PanelManager protocol, along with the managerViewController that will be presenting these panels, is the primary panelContentView. This is the main focused view you want to get out of your way if you pin another view controller. I want my PostContentViewController to be focused on the bodyField, so I let that fill the screen and define that as my panelContentView.

By doing this and setting allowPanelPinning = true on my PanelManager I’m able to pin view controllers to the edges of my content view.


class PostContentManager: PanelManager {
    var postContentViewController: PostContentViewController
    var managerViewController: UIViewController {
        return postContentViewController }

    var allowPanelPinning: Bool {
        return panelContentWrapperView.bounds.width > 350 }

    func maximumNumberOfPanelsPinned(at side: PanelPinSide) -> Int {
        switch side {
        case .right:
            return 3
        case .left:
            return 1
        default:
            return 2
        }
    }
}

Pinning Detail Views

I don’t allow floating or pinning panels on iPhone. I briefly experimented with that but wasn’t able to get it working, probably need to take a closer look at all the optional values on the PanelManager. What I do instead is put all of those PostInfoComponents view controllers within a MetaViewController panel, which just gets presented modally with a close button instead of an unpin button. And from the iPad that view can unpin any of those sub views into a new panel! There’s no limit to how deep you can go with presenting these panels from within each other, and believe me I tried 😆

Meta Details on iPhone

One thing I’m curious to try next, the date/time picker is really wide, and needs ~400pts to not clip the text. I’d like to play around with auto-adjusting constraints, curious to see how it looks if content dances around in the window while it’s being resized. More likely though I’ll just hide the year on the picker. I also want to do a lot more with the body field, make it a full featured editing view with Markdown preview.

This was only a weekend sprint, but already I’m very happy with the state of this, and it’s making me really excited to see how Apple implements this feature. I’m sure it will be pretty distinct from this library, but I really liked that this was almost entirely protocol driven and hope Apple at least brings that design pattern to production.

Either way it was a fun experiment into thinking with panels, and I’m glad I got a small preview of the work I’m gonna have ahead of me this June!

External Storage Rumors for iOS 13

New APIs in iOS 13 - Rambo @ 9to5mac

Man, these scoops from Rambo just won’t stop until June I guess?

Some interesting stuff in here, I’m particularly excited for a Search Siri Intent that will go a long way to actually making Siri Intents usable for lots of classes of apps.

One thing I’m concerned about however is the note about external device access APIs.

With a new API, apps will be able to capture photos from external devices such as cameras and SD cards, without having to go through the Photos app.

This doesn’t need to mean that there won’t be Files level access to external storage, but suddenly I’m concerned that they’ll just add an API to the same photo import access they already use in the Photos app and call that done until next year. Fingers crossed that this API is on top of general improvements to external storage access including allowing the contents to show in Files and be used by any document based app. 🤞

AirPower Canceled

Apple Cancels AirPower

This is crazy, considering all the signs point to this being close to a launch. I guess now I can finally release that AirPower money I had blocked off for a release this week.

This is a bummer, Apple obviously felt like this was important and close enough to show off. I can’t imagine how hard the engineering team was working on this for the last year.

I wonder when the decision was made? Definitely seems like AirPods we’re ready for a launch in 2018 that was waiting for AirPower, so I can’t imagine it was any more than a month or so ago.

We’re Trying: A Family Podcast

My wife and I are launching a podcast next month. We’re also going to start trying to start a family, which is the much, much bigger deal of the two, but I think focusing on the smaller, more manageable task is helping me keep myself collected.

We’ve been talking about having kids forever, and for various reasons, none of which are terribly interesting if you aren’t in our marriage, we picked a date in March to start rolling the dice.

This is obviously a very personal thing, something many couples who plan to have kids woudn’t share for fear of jinxing it or just oversharing. A watched pot never boils, and that sort of mentality. So why in the world would I announce it all over the internet?

For one, I didn’t want us to go through this alone. We stay in touch with our families and some old friends, but we don’t really have many people in the Boston area who we would typically share things like this with. I’m certain that as this process goes on we’ll have plenty to be excited about, nervous, or anxious about. Doing this show gives us the opportunity to give regular updates to people who care about us and would want to know how it’s going.

Also, I’ve been very touched by the stories I’ve read online documenting people’s struggles with creating families of their own. Casey Liss and Daniel Farrelly in particular come to mind, but I’ve read countless stories from people who feel, at various points in the process, alone, scared, hopeless, or depressed. I don’t know what’s in the cards for us, how easy or difficult this is going to be, or what setbacks we’re going to encounter. I do know that I don’t want to feel like we’re the only ones going through our struggle, and I’m hoping being open about our experiences can either help ourselves or help others during this exciting, terrifying time.

But mostly, I’m just hoping that this will be a fun thing for Rosalee and I to do together. I think it’ll be good to have dedicated time set aside to check in with each other, make sure we’re ok, laugh, cry, do whatever we need to do to sync up. I love my wife so much, and I love finding opportunities to spend time with her.

The show is called We’re Trying, which I thought was very funny. I don’t really care if you do or not 😆. I wanted it to evoke not only literal truth of what we’re doing, but also get across that we’re just going to be trying our best. At times along the way we will certainly fail or screw up, but at the end of the day the best you can do is give it your all, and that’s what we’re gonna do. And if we like this experience, maybe we’ll continue the show after we have the kid! Chronicle the first years and all that. I guess I just like the idea of archiving this experience for the same reason I run this blog and take so many pictures of my wife and cat. So I have a reference for a time in life I expect to love :)

So if you wanna follow along on our journey you can find the show on Apple Podcasts and subscribe now. We’ll be publishing the first episode sometime in mid-March, so keep an eye out!

We’re Trying Show Art

null_resettable Equivalent In Swift

Swift doesn’t have an annotation to indicate what in ObjC would be a null_resettable property. These are properties like UILabel.font, which never will return nil, but do allow you to set nil to reset to a default value.

In the past I’ve gotten around this by defining a get-only non-optional variable, and a function that acts as a setter which takes an optional. What I just learned from a coworker is that there’s a much better way to do this!

If you define your variable as an implicitly unwrapped type, in the set closure you can handle a nil case! So you can define a computed variable that resets to a default value when you define it as nil as such:


var foo: Bar! = .default {
  get {
    // Do some work to generate the variable here
  }
  set {
    var toSet: Bar = newValue ?? .default
    // Do whatever with the non-optional variable
  }
}

Hope this proves helpful to some of y’all! It makes me nervous as hell adding a bang to my Swift code, but this seems like one of those few times where it’s a great idea!