SwiftUI: Animating Panel Content

I’m finally getting my hands dirty with SwiftUI and generally find it more motivating to build components that I actually need. So I thought perhaps that it’s time I re-write my Songbook Simple and Recipebox Simple apps to leverage all the experience I’ve gained with Swift, Design Patterns, Test-driven development, and to see how I might build the app in SwiftUI.

Most of Songbook’s UI is pretty simple and straightforward, with one exception: the Main Container UI. Basically Songbook has 3 principal UI components: The Song / Content View (rendering text or images), a Menu panel that slides in/out, and a toolbar that can show/hide.

I basically took this blog post, married it with this other blog post by the same author, to get an understanding of how to make a side panel that animates in and out, then take this approach and see if it can work in 2 dimensions. It wasn’t as straightforward to do the bottom panel, but it did teach me about the importance of GeometryReader and gave me some insights as to how SwiftUI handles state changes, and how to build a view hierarchy that takes that into consideration.

I am grateful to be able to stand on someone else’s shoulders to get me far enough along, after which I synthesized the rest from the knowledge gained. So I’ll just jump to how I got to the result.

It ultimately yielded these components:

//
//  ControlsOverlayView.swift
//  BookItemsSuite
//
//  Created by Stephen O'Connor on 11.03.21.
//

import SwiftUI

struct ControlsOverlayView<ToolbarContent: View, MenuContent: View>: View {
    
    let menuWidth: CGFloat
    let isMenuActive: Bool
    let onMenuHide: () -> Void
    
    let menuContent: MenuContent
    
    let toolbarHeight: CGFloat
    let isToolbarActive: Bool
    let onToolbarHide: () -> Void
    
    let toolbarContent: ToolbarContent
    
    init(menuWidth: CGFloat = 270,
         isMenuActive: Bool = true,
         onMenuHide: @escaping () -> Void,
         toolbarHeight: CGFloat = 44,
         isToolbarActive: Bool = true,
         onToolbarHide: @escaping () -> Void,
         @ViewBuilder menuContent: () -> MenuContent,
         @ViewBuilder toolbarContent: () -> ToolbarContent) {
        
        self.menuWidth = menuWidth
        self.isMenuActive = isMenuActive
        self.onMenuHide = onMenuHide
        self.menuContent = menuContent()
        
        self.toolbarHeight = toolbarHeight
        self.isToolbarActive = isToolbarActive
        self.onToolbarHide = onToolbarHide
        self.toolbarContent = toolbarContent()
    }
    
    var body: some View {
        ZStack {
            GeometryReader { _ in
                EmptyView()
            }
            .background(Color.gray.opacity(0.3))
            .opacity(self.isMenuActive ? 1.0 : 0.0)
            .animation(Animation.easeIn.delay(0.25))
            .onTapGesture {
                self.onMenuHide()
            }
            
            GeometryReader { geometry in
                VStack {
                    let toolbarHeight = isToolbarActive ? self.toolbarHeight : 0
                    HStack {
                        Spacer()
                        
                        let space: CGFloat = 0.0 //geometry.size.width - self.menuWidth
                        let offset = self.isMenuActive ? space : space + self.menuWidth
                        self.menuContent
                            .frame(width: self.menuWidth, height: geometry.size.height - toolbarHeight, alignment: .center)
                            .background(Color.red)
                            .offset(x: offset)
                            .animation(.default)
                    }
                    .frame(width: geometry.size.width,
                           height: geometry.size.height - toolbarHeight,
                           alignment: .center)
                    
                    let offset = self.isToolbarActive ? 0 : self.toolbarHeight/2
                    
                    self.toolbarContent
                        .frame(width: geometry.size.width,
                               height: self.toolbarHeight,
                               alignment: .center)
                        .background(Color.yellow)
                        .offset(y: offset)
                        .animation(.default)
                    
                }
            }
        }
    }
}

struct ControlsOverlayView_Previews: PreviewProvider {
    static var previews: some View {
        ControlsOverlayView(menuWidth: 270,
                            isMenuActive: true,
                            onMenuHide: {},
                            toolbarHeight: 44,
                            isToolbarActive: true,
                            onToolbarHide: {}) {
            Text("Menu Content")
        } toolbarContent: {
            Text("Toolbar Content")
        }
    }
}

Which is then a part of the ApplicationView (in UIKit terms, this would equate to a root view controller):

//
//  ApplicationView.swift
//  SidePanelSandbox
//
//  Created by Stephen O'Connor on 10.03.21.
//

import SwiftUI

struct ApplicationView<MainContent: View, MenuContent: View, ToolbarContent: View>: View {
    
    @State var isMenuActive: Bool = true
    @State var isToolbarActive: Bool = false
    
    var menuWidth: CGFloat = 270
    
    let mainContent: MainContent
    let menuContent: MenuContent
    let toolbarContent: ToolbarContent
    
    init(@ViewBuilder mainContent: () -> MainContent,
                      menuContent: () -> MenuContent,
                      toolbarContent: () -> ToolbarContent) {
        self.mainContent = mainContent()
        self.menuContent = menuContent()
        self.toolbarContent = toolbarContent()
    }
    
    var body: some View {
        ZStack {
            GeometryReader { geometry in
                self.mainContent
                    .frame(width: geometry.size.width, height: geometry.size.height, alignment: .center)
                   
            }
            if !self.isMenuActive {
                GeometryReader { _ in
                    VStack {
                        Button(action: {
                            self.toggleMenu()
                        }, label: {
                            Text("Menu")
                        })
                        
                        Button(action: {
                            self.toggleToolbar()
                        }, label: {
                            Text("Toolbar")
                        })
                    }
                }
            }
            
            ControlsOverlayView(menuWidth: 270, isMenuActive: isMenuActive, onMenuHide: {
                self.toggleMenu()
                print("did hide menu")
            }, toolbarHeight: 44, isToolbarActive: isToolbarActive, onToolbarHide: {
                print("did hide toolbar")
            }, menuContent: {
                self.menuContent
            }, toolbarContent: {
                self.toolbarContent
            })
        }
    }
    
    func toggleMenu() {
        if self.isMenuActive {
            // if we're about to close the menu, close the toolbar too.
            $isToolbarActive.wrappedValue = false
        }
        self.isMenuActive.toggle()
    }
    
    func toggleToolbar() {
        self.isToolbarActive.toggle()
    }
}

struct ApplicationView_Previews: PreviewProvider {
    static var previews: some View {
        ApplicationView {
            Text("Main Content")
        } menuContent: {
            Text("Menu Content")
        } toolbarContent: {
            Text("Toolbar Content")
        }
    }
}

And that’s essentially how it works. I was and still am a little confused as to why the ControlsOverlayView is rendering as expected, given the way I offset the toolbar, but all in all I think this approach is pretty clean and I’m happy with it.

[Recipe] Get the current iOS Simulator App’s location on disk

I’m currently doing SwiftUI tutorials, and found this useful command for Terminal. When your app is running in a simulator (make sure you only have 1 simulator window open!) and you want to get close to its Documents folder on disk, put this in a terminal:

xcrun simctl get_app_container booted <my_bundle_identifier_without_brackets>

And this will take you to the folder containing the AppName.app file. From there you’ll want to go up a few folders back to Container level, and then go into the Data folder, and ultimately find your app’s identifying folder. (Hint: Look at the Date Modified field… it will likely indicate which folder corresponds to your app)

I enjoyed this post because I used to accomplish this goal but not as elegantly (and quickly, I suppose) has here. I wrote it here to be able to refer to it again someday.

Regular Expressions on iOS

I’ve decided to take on the masochistic task of getting my hands dirty with NSRegularExpression. Here are some of my thoughts and findings:

– Regex is voodoo. What’s worse, it’s not even standardized. So all the tutorials on the web are not platform agnostic. I could use a recipe to match the things I’m interested in, but when creating an instance of NSRegularExpression on iOS, it fails as “invalid pattern”.
– Because of this, you WILL become a test driven developer. 🙂
– Start with a simple regex pattern that detects PARTS of what you’re looking to match, then add complexity. This way you can see what part of your pattern is failing.
– Don’t use the Number Sign # as a matchable character. It is protected but not documented! Use \u0023 instead. e.g. “\\b
– When you map/inspect your NSTextCheckingResult objects, it should have the same number of ranges as you have capture groups. You use these to get more specific about which capture groups were involved in the match. (Provides more granularity/control). The NSTextCheckingResult.range represents the range in the text associated with that one result!
– NSRegularExpression really doesn’t deal well with the hash symbol (sharps).

I ultimately reached a solution, but it never felt entirely ‘correct’ despite finding a way to produce the correct results.