swiftuimobile

Multiplatform SwiftUI app with a next layout: a top bar, bottom tab bar and a viewport in the middle


I'd like to create my pet project on SwiftUI for iPhone, iPad and MacOs (Mac Catalyst). My idea to have the next layout on all devices: A top bar (with buttons) above, a bottom tab bar at the bottom, and a Viewport view in the middle, taking all remaining area.

I also would like to get the size of the Viewport and save it in a global variable (ObservableObject): But every time I have some issue on different devices (sometimes the Viewport covers all screen or overlapping with a top bar or with a tab bar.

class GlobalUI: ObservableObject {
  @Published var viewportSize: CGSize = .zero // Store available viewport size
}


struct ContentView: View {
@EnvironmentObject var globalUI: GlobalUI
@State var selectedTab: TabSelection = .first

var body: some View {
    GeometryReader { geometry in
        VStack(spacing: 0) {
            // Top Bar
            CustomTopBar(selectedTab: selectedTab)
                .ignoresSafeArea(edges: .top)
            
            // Main Viewport
            Viewport(selectedTab: selectedTab)
                .frame(height: geometry.size.height - 100)
            
            // Bottom Tab Bar
            CustomTabBar(selectedTab: $selectedTab)
                .frame(height: 50)
                .padding(.bottom, getSafeAreaBottomInset())
                .background(Color("BrandGreen"))
                .ignoresSafeArea(edges: .bottom)
        }
    }
    .background(Color.black)
    .edgesIgnoringSafeArea(.all)
}
}

// another attempt:

struct ContentView: View {
@EnvironmentObject var globalUI: GlobalUI
@State var selectedTab: TabSelection = .first

func getSafeAreaBottomInset() -> CGFloat {
    guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else {
        return 0
    }
    return windowScene.windows.first?.safeAreaInsets.bottom ?? 0
}

var body: some View {
    VStack(spacing: 0) {
        CustomTopBar(selectedTab: selectedTab)
            .expandViewOutOfSafeArea(.top)
        
        GeometryReader { geometry in
            VStack(spacing: 0) {
                Viewport(selectedTab: selectedTab)
                    .onAppear {
                        let newSize = geometry.size
                        if newSize.width > 0 && newSize.height > 0 {
                            globalUI.viewportSize = newSize
                            print("viewport size updated: \(newSize)")
                        }
                    }
                    .onChange(of: geometry.size) { newSize in
                        if newSize.width > 0 && newSize.height > 0 {
                            globalUI.viewportSize = newSize
                            print("viewport size updated: \(newSize)")
                        }
                    }
                    .frame(maxWidth: .infinity, maxHeight: geometry.size.height - getSafeAreaBottomInset() - 100)  // Adjust height to exclude CustomTabBar height

                Spacer()
            }
        }
        
        CustomTabBar(selectedTab: $selectedTab)
            .frame(height: 50)
            .padding(.bottom, getSafeAreaBottomInset())
            .background(Color("BrandGreen")) // Ensures a solid background
    }
    .frame(maxWidth: .infinity, maxHeight: .infinity)
    .background(Color.black)
    .ignoresSafeArea(edges: [.horizontal, .bottom])
}
}

Solution

  • Instead of forcing the Viewport to adopt the size you give it, just let it expand to use all the space available. This is done by applying a frame with maxWidth: .infinity, maxHeight: .infinity.

    If you need to know the size of the viewport, use .onGeometryChange to read it. A GeometryReader is not needed.

    Btw, when you apply a background color using background(_:ignoresSafeAreaEdges:), the safe area insets are ignored by default. So there is no need to add a modifier to ignore them explicitly.

    struct ContentView: View {
        @EnvironmentObject var globalUI: GlobalUI
        @State var selectedTab: TabSelection = .first
    
        var body: some View {
            VStack(spacing: 0) {
                // Top Bar
                CustomTopBar(selectedTab: selectedTab)
                    .ignoresSafeArea(edges: .top)
    
                // Main Viewport
                Viewport(selectedTab: selectedTab)
                    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
                    .onGeometryChange(for: CGSize.self) { proxy in
                        proxy.size
                    } action: { size in
                        globalUI.viewportSize = size
                    }
    
                // Bottom Tab Bar
                CustomTabBar(selectedTab: $selectedTab)
                    .frame(height: 50)
                    .background(Color("BrandGreen"))
            }
            .background(.black)
        }
    }