How to detect device rotation in SwiftUI and re-draw view components?
I have a @State variable initialized to the value of UIScreen.main.bounds.width when the first
Here is an abstraction that allows you to wrap any part of your view tree in optional orientation based behavior, as a bonus, it doesn't rely on UIDevice orientation but instead bases it on the geometry of the space, this allows it to work in swift preview, as well as provide logic for different layouts based specifically on the container for your view:
struct OrientationView<L: View, P: View> : View {
let landscape : L
let portrait : P
var body: some View {
GeometryReader { geometry in
Group {
if geometry.size.width > geometry.size.height { self.landscape }
else { self.portrait }
}.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
init(landscape: L, portrait: P) {
self.landscape = landscape
self.portrait = portrait
}
}
struct OrientationView_Previews: PreviewProvider {
static var previews: some View {
OrientationView(landscape: Text("Landscape"), portrait: Text("Portrait"))
.frame(width: 700, height: 600)
.background(Color.gray)
}
}
Usage: OrientationView(landscape: Text("Landscape"), portrait: Text("Portrait"))
I think easy repainting is possible with addition of
@Environment(\.verticalSizeClass) var sizeClass
to View struct.
I have such example:
struct MainView: View {
@EnvironmentObject var model: HamburgerMenuModel
@Environment(\.verticalSizeClass) var sizeClass
var body: some View {
let tabBarHeight = UITabBarController().tabBar.frame.height
return ZStack {
HamburgerTabView()
HamburgerExtraView()
.padding(.bottom, tabBarHeight)
}
}
}
As you can see I need to recalculate tabBarHeight to apply correct bottom padding on Extra View, and addition of this property seems to correctly trigger repainting.
With just one line of code!
I wanted to know if there is simple solution within SwiftUI that works with any enclosed view so it can determine a different landscape/portrait layout. As briefly mentioned by @dfd GeometryReader can be used to trigger an update.
Note that this works in the special occasions where use of the standard size class/traits do not provide sufficient information to implement a design. For example, where a different layout is required for portrait and landscape but where both orientations result in a standard size class being returned from the environment. This happens with the largest devices, like the max sized phones and with iPads.
This is the 'naive' version and this does not work.
struct RotatingWrapper: View {
var body: some View {
GeometryReader { geometry in
if geometry.size.width > geometry.size.height {
LandscapeView()
}
else {
PortraitView()
}
}
}
}
This following version is a variation on a rotatable class that is a good example of function builders from @reuschj but just simplified for my application requirements https://github.com/reuschj/RotatableStack/blob/master/Sources/RotatableStack/RotatableStack.swift
This does work
struct RotatingWrapper: View {
func getIsLandscape(geometry:GeometryProxy) -> Bool {
return geometry.size.width > geometry.size.height
}
var body: some View {
GeometryReader { geometry in
if self.getIsLandscape(geometry:geometry) {
Text("Landscape")
}
else {
Text("Portrait").rotationEffect(Angle(degrees:90))
}
}
}
}
That is interesting because I'm assuming that some SwiftUI magic has caused this apparently simple semantic change to activate the view re-rendering.
One more weird trick that you can use this for, is to 'hack' a re-render this way, throw away the result of using the GeometryProxy and perform a Device orientation lookup. This then enables use of the full range of orientations, in this example the detail is ignored and the result used to trigger a simple portrait and landscape selection or whatever else is required.
enum Orientation {
case landscape
case portrait
}
struct RotatingWrapper: View {
func getOrientation(geometry:GeometryProxy) -> Orientation {
let _ = geometry.size.width > geometry.size.height
if UIDevice.current.orientation == UIDeviceOrientation.landscapeLeft || UIDevice.current.orientation == UIDeviceOrientation.landscapeRight {
return .landscape
}
else {
return .portrait
}
}
var body: some View {
ZStack {
GeometryReader { geometry in
if self.getOrientation(geometry: geometry) == .landscape {
LandscapeView()
}
else {
PortraitView()
}
}
}
}
}
Furthermore, once your top level view is being refreshed you can then use DeviceOrientation directly, such as the following in child views as all child views will be checked once the top level view is 'invalidated'
Eg: In the LandscapeView() we can format child views appropriately for its horizontal position.
struct LandscapeView: View {
var body: some View {
HStack {
Group {
if UIDevice.current.orientation == UIDeviceOrientation.landscapeLeft {
VerticallyCenteredContentView()
}
Image("rubric")
.resizable()
.frame(width:18, height:89)
//.border(Color.yellow)
.padding([UIDevice.current.orientation == UIDeviceOrientation.landscapeLeft ? .trailing : .leading], 16)
}
if UIDevice.current.orientation == UIDeviceOrientation.landscapeRight {
VerticallyCenteredContentView()
}
}.border(Color.pink)
}
}
Inspired by @caram solution, I grab the isLandscape
property from windowScene
In SceneDelegate.swift
, get the current orientation from window.windowScene.interfaceOrientation
...
var model = Model()
...
func windowScene(_ windowScene: UIWindowScene, didUpdate previousCoordinateSpace: UICoordinateSpace, interfaceOrientation previousInterfaceOrientation: UIInterfaceOrientation, traitCollection previousTraitCollection: UITraitCollection) {
model.isLandScape = windowScene.interfaceOrientation.isLandscape
}
In this way, we'll get true
from the start if the user launches the app from the landscape mode.
Here is the Model
class Model: ObservableObject {
@Published var isLandScape: Bool = false
}
And we can use it in the exact same way as @kontiki suggested
struct ContentView: View {
@EnvironmentObject var model: Model
var body: some View {
Group {
if model.isLandscape {
Text("LANDSCAPE")
} else {
Text("PORTRAIT")
}
}
}
}
It's easy to go without notifications, delegation methods, events, changes to SceneDelegate.swift
, window.windowScene.interfaceOrientation
and so on.
try running this in simulator and rotating device.
struct ContentView: View {
let cards = ["a", "b", "c", "d", "e"]
@Environment(\.horizontalSizeClass) var horizontalSizeClass
var body: some View {
let arrOfTexts = {
ForEach(cards.indices) { (i) in
Text(self.cards[i])
}
}()
if (horizontalSizeClass == .compact) {
return VStack {
arrOfTexts
}.erase()
} else {
return VStack {
HStack {
arrOfTexts
}
}.erase()
}
}
}
extension View {
func erase() -> AnyView {
return AnyView(self)
}
}
This seems to work for me. Then just init and use Orientation instance as environmentobject
class Orientation: ObservableObject {
let objectWillChange = ObservableObjectPublisher()
var isLandScape:Bool = false {
willSet {
objectWillChange.send() }
}
var cancellable: Cancellable?
init() {
cancellable = NotificationCenter.default
.publisher(for: UIDevice.orientationDidChangeNotification)
.map() { _ in (UIDevice.current.orientation == .landscapeLeft || UIDevice.current.orientation == .landscapeRight)}
.removeDuplicates()
.assign(to: \.isLandScape, on: self)
}
}