Declaring relative size to main screen size for SwiftUI view
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
Sometimes there is a need to declare size of particular View
to be relative to the main screen of application. While it’s really easy to achieve for UIKit based apps by using globally shared singletone UIScreen.main.bounds
property, it’s not a case for SwiftUI based app, since UIKit objects only relevant to iOS\iPadOS\Mac Catalyst app. In addition things are going even more crazy, when you need to be notified about device rotation in order to grab updated width/height values form the UIScreen.main
.
It would be great to handle this situation purely within SwiftUI tools without relaying on platform-specific #if #else
statements switching between UIScreen
, NSScreen
, WKInterfaceDevice
and wrapping UIHostingController
. Using GeometryReader
you can achieve the goal. Let’s see a way how it can be implemented.
Let’s assume some view need to be 60% of main screen width. The very naive implementation can be:
The problem is that works only for top level content view. If you are applying this technique deeper in the view hierarchy, chances it will work wrong are very high:
As you can see from the screenshot, now 60% of width is divided between view itself plus TextView
from the top level of view hierarchy. And it’s not surprise, if you are familiar with how GeometryReader actually works. WIth this knowledge in mind, let’s wrap top container view into GeometryReader
and pass proxy size data down to the hierarchy as environment object. It will allow to use size info from the top level view anywhere deep in the view hierarchy.
First of all, lets create ObservableObject to store size info:
SubViiew will leverage knowledge of top view’s size and use it to make specific frame size:
And the top level view implementation may look like this:
So far so good. Actually it’s not! This code will lead to endless cycle of view updates and system will never be able to actually draw the view. That is because every new input from GeometryReader
will cause sizeProvider
to update within size from GeometryProxy
which will lead to body
reevaluating since sizeProvider
is StateObject
. And again. And again.
Instead of sizeProvider
being wrapped as StateObject
, let’s make it just a regular property. And even more, let’s delegate creating of this property to the very top level - SceneDelegate
in case of iOS 13 or Scene
in case of iOS 14:
and change @StateObject var sizeProvider: SizeProvider = SizeProvider()
to let sizeProvider: SizeProvider
in ContentView
. That’s it. Every view down in the hierarchy may use size of GeometryReader
from the very top view. In addition screen rotation changes automatically handled by SwiftUI framework without any extra line of code on client side.
One consideration here is that GeometryProxy
size property will contain size of the window excluding safe area insets. That is the reason why SizeProvider
has let safeAreaInsets: EdgeInsets
property in addition to let size: CGSize
. In order to get full size of the app window you need to take into account safe area insets, if they are present in top view.
Result in iOS app:
Result in macOS app:
Thanks for reading.
Source code
You can find complete project on GitHub targeting macOS and iOS platforms.