How to create an SwiftUI animation effect from the Model? - swiftui

I have a model object, which has a published property displayMode, which is updated asynchronously via events from the server.
class RoomState: NSObject, ObservableObject {
public enum DisplayMode: Int {
case modeA = 0
case modeB = 1
case modeC = 2
}
#Published var displayMode = DisplayMode.modeA
func processEventFromServer(newValue: DisplayMode) {
DispatchQueue.main.async {
self.displayMode = newValue
}
}
}
Then, I have a View, which displays this mode by placing some image in a certain location depending on the value.
struct RoomView: View {
#ObservedObject var state: RoomState
var body: some View {
VStack {
...
Image(systemName: "something")
.offset(x: state.displayMode.rawValue * 80, y:0)
}
}
}
This code works fine, but I want to animate the movement when the value changes. If I change the value in the code block inside the View, I can use withAnimation {..} to create an animation effect, but I am not able to figure out how to do it from the model.

This is the answer, thanks to #aheze. With .animation(), this Image view always animates when the state.displayMode changes.
struct RoomView: View {
#ObservedObject var state: RoomState
var body: some View {
VStack {
...
Image(systemName: "something")
.offset(x: state.displayMode.rawValue * 80, y:0)
.animation(.easeInOut)
}
}
}

Related

Why does this SwiftUI LazyHStack update continuously?

I have a large set of URLs to images. I display the files' thumbnails in a LazyVStack. I have wrapped up the 'ThumbnailView' and the 'ThumbnailGenerator' in a struct and class respectively. However, when I ran the code I discovered that it kept re-initaiting the ThumbnailGenerators. After some investigation I found that after removing an HStack in the main view's hierarchy the problem went away.
Any thoughts as to why this might happen. (BTW I did log this with Apple, but still feel I am doing something wrong here myself.)
I have stripped the code back to the bare essentials here, replacing the thumbnail generation code with a simple sleep statement, to demonstrate the bug in action. Run it with the HStack in and it will print out the date continuously. Take it out and it works as expected.
#main
struct ExperimentApp: App {
var body: some Scene {
WindowGroup {
LazyVIssue()
.frame(width: 200, height: 140)
.padding(100)
}
}
}
struct LazyVIssue: View {
var body: some View {
ScrollView {
LazyVStack {
ForEach(0..<10) { i in
HStack { /// <---- REMOVE THIS HSTACK AND IT WORKS
ThumbnailView()
Text("Filename \(i)")
}.padding()
}
}
}
}
}
struct ThumbnailView: View {
#StateObject private var thumbnailGenerator : ThumbnailGenerator
init() {
_thumbnailGenerator = StateObject(wrappedValue: ThumbnailGenerator())
}
var body: some View {
thumbnailGenerator.image
}
}
final class ThumbnailGenerator: ObservableObject {
var image : Image
init() {
print("Initiating", Date())
image = Image(systemName: "questionmark.circle.fill")
DispatchQueue.global(qos: .userInteractive).async { [weak self] in
guard let self = self else { return }
sleep(1) /// Simulate some work to fetch image
self.image = Image(systemName: "camera.circle.fill")
DispatchQueue.main.async {
self.objectWillChange.send()
}
}
}
}
I'm not sure why this is happening but I've seen had some funky things happen like this as well. If you initialize the ThumbnailGenerator() outside of the ThumbnailView init, I believe the issue goes away.
init(generator: ThumbnailGenerator) {
_thumbnailGenerator = StateObject(wrappedValue: generator)
}
Well, it is not clear for now what's going on here definitely (it is something about LazyVStack caching), but there is workaround - move everything into single row view.
Tested with Xcode 12.1 / iOS 14.1
struct LazyVIssue: View {
var body: some View {
ScrollView {
LazyVStack {
ForEach(0..<10) { i in
ThumbnailView(i) // << single row view !!
}
}
}
}
}
struct ThumbnailView: View {
#StateObject private var thumbnailGenerator : ThumbnailGenerator
let row: Int
init(_ row: Int) {
self.row = row
_thumbnailGenerator = StateObject(wrappedValue: ThumbnailGenerator())
}
var body: some View {
HStack {
thumbnailGenerator.image
Text("Filename \(row)")
}.padding()
}
}

Why SwiftUI-transition does not work as expected when I use it in UIHostingController?

I'm trying to get a nice transition for a view that needs to display date. I give an ID to the view so that SwiftUI knows that it's a new label and animates it with transition. Here's the condensed version without formatters and styling and with long duration for better visualisation:
struct ContentView: View {
#State var date = Date()
var body: some View {
VStack {
Text("\(date.description)")
.id("DateLabel" + date.description)
.transition(.slide)
.animation(.easeInOut(duration: 5))
Button(action: { date.addTimeInterval(24*60*60) }) {
Text("Click")
}
}
}
}
Result, it's working as expected, the old label is animating out and new one is animating in:
But as soon as I wrap it inside UIHostingController:
struct ContentView: View {
#State var date = Date()
var body: some View {
AnyHostingView {
VStack {
Text("\(date.description)")
.id("DateLabel" + date.description)
.transition(.slide)
.animation(.easeInOut(duration: 5))
Button(action: { date.addTimeInterval(24*60*60) }) {
Text("Click")
}
}
}
}
}
struct AnyHostingView<Content: View>: UIViewControllerRepresentable {
typealias UIViewControllerType = UIHostingController<Content>
let content: Content
init(content: () -> Content) {
self.content = content()
}
func makeUIViewController(context: Context) -> UIHostingController<Content> {
let vc = UIHostingController(rootView: content)
return vc
}
func updateUIViewController(_ uiViewController: UIHostingController<Content>, context: Context) {
uiViewController.rootView = content
}
}
Result, the new label is not animated in, rather it's just inserted into it's final position, while the old label is animating out:
I have more complex hosting controller but this demonstrates the issue. Am I doing something wrong with the way I update the hosting controller view, or is this a bug in SwiftUI, or something else?
State do not functioning well between different hosting controllers (it is not clear if this is limitation or bug, just empirical observation).
The solution is embed dependent state inside hosting view. Tested with Xcode 12.1 / iOS 14.1.
struct ContentView: View {
var body: some View {
AnyHostingView {
InternalView()
}
}
}
struct InternalView: View {
#State private var date = Date() // keep relative state inside
var body: some View {
VStack {
Text("\(date.description)")
.id("DateLabel" + date.description)
.transition(.slide)
.animation(.easeInOut(duration: 5))
Button(action: { date.addTimeInterval(24*60*60) }) {
Text("Click")
}
}
}
}
Note: you can also experiment with ObservableObject/ObservedObject based view model - that pattern has different life cycle.

SwifUI onAppear gets called twice

Q1: Why are onAppears called twice?
Q2: Alternatively, where can I make my network call?
I have placed onAppears at a few different place in my code and they are all called twice. Ultimately, I'm trying to make a network call before displaying the next view so if you know of a way to do that without using onAppear, I'm all ears.
I have also tried to place and remove a ForEach inside my Lists and it doesn't change anything.
Xcode 12 Beta 3 -> Target iOs 14
CoreData enabled but not used yet
struct ChannelListView: View {
#EnvironmentObject var channelStore: ChannelStore
#State private var searchText = ""
#ObservedObject private var networking = Networking()
var body: some View {
NavigationView {
VStack {
SearchBar(text: $searchText)
.padding(.top, 20)
List() {
ForEach(channelStore.allChannels) { channel in
NavigationLink(destination: VideoListView(channel: channel)
.onAppear(perform: {
print("PREVIOUS VIEW ON APPEAR")
})) {
ChannelRowView(channel: channel)
}
}
.listStyle(GroupedListStyle())
}
.navigationTitle("Channels")
}
}
}
}
struct VideoListView: View {
#EnvironmentObject var videoStore: VideoStore
#EnvironmentObject var channelStore: ChannelStore
#ObservedObject private var networking = Networking()
var channel: Channel
var body: some View {
List(videoStore.allVideos) { video in
VideoRowView(video: video)
}
.onAppear(perform: {
print("LIST ON APPEAR")
})
.navigationTitle("Videos")
.navigationBarItems(trailing: Button(action: {
networking.getTopVideos(channelID: channel.channelId) { (videos) in
var videoIdArray = [String]()
videoStore.allVideos = videos
for video in videoStore.allVideos {
videoIdArray.append(video.videoID)
}
for (index, var video) in videoStore.allVideos.enumerated() {
networking.getViewCount(videoID: videoIdArray[index]) { (viewCount) in
video.viewCount = viewCount
videoStore.allVideos[index] = video
networking.setVideoThumbnail(video: video) { (image) in
video.thumbnailImage = image
videoStore.allVideos[index] = video
}
}
}
}
}) {
Text("Button")
})
.onAppear(perform: {
print("BOTTOM ON APPEAR")
})
}
}
I had the same exact issue.
What I did was the following:
struct ContentView: View {
#State var didAppear = false
#State var appearCount = 0
var body: some View {
Text("Appeared Count: \(appearrCount)"
.onAppear(perform: onLoad)
}
func onLoad() {
if !didAppear {
appearCount += 1
//This is where I loaded my coreData information into normal arrays
}
didAppear = true
}
}
This solves it by making sure only what's inside the the if conditional inside of onLoad() will run once.
Update: Someone on the Apple Developer forums has filed a ticket and Apple is aware of the issue. My solution is a temporary hack until Apple addresses the problem.
I've been using something like this
import SwiftUI
struct OnFirstAppearModifier: ViewModifier {
let perform:() -> Void
#State private var firstTime: Bool = true
func body(content: Content) -> some View {
content
.onAppear{
if firstTime{
firstTime = false
self.perform()
}
}
}
}
extension View {
func onFirstAppear( perform: #escaping () -> Void ) -> some View {
return self.modifier(OnFirstAppearModifier(perform: perform))
}
}
and I use it instead of .onAppear()
.onFirstAppear{
self.vm.fetchData()
}
you can create a bool variable to check if first appear
struct VideoListView: View {
#State var firstAppear: Bool = true
var body: some View {
List {
Text("")
}
.onAppear(perform: {
if !self.firstAppear { return }
print("BOTTOM ON APPEAR")
self.firstAppear = false
})
}
}
Let us assume you are now designing a SwiftUI and your PM is also a physicist and philosopher. One day he tells you we should to unify UIView and UIViewController, like Quantum Mechanics and the Theory of Relativity. OK, you are like-minded with your leader, voting for "Simplicity is Tao", and create an atom named "View". Now you say: "View is everything, view is all". That sounds awesome and seems feasible. Well, you commit the code and tell the PM….
onAppear and onDisAppear exists in every view, but what you really need is a Page lifecycle callback. If you use onAppear like viewDidAppear, then you get two problems:
Being influenced by the parent, the child view will rebuild more than one time, causing onAppear to be called many times.
SwiftUI is closed source, but you should know this: view = f(view). So, onAppear will run to return a new View, which is why onAppear is called twice.
I want to tell you onAppear is right! You MUST CHANGE YOUR IDEAS. Don’t run lifecycle code in onAppear and onDisAppear! You should run that code in the "Behavior area". For example, in a button navigating to a new page.
You can create the first appear function for this bug
extension View {
/// Fix the SwiftUI bug for onAppear twice in subviews
/// - Parameters:
/// - perform: perform the action when appear
func onFirstAppear(perform: #escaping () -> Void) -> some View {
let kAppearAction = "appear_action"
let queue = OperationQueue.main
let delayOperation = BlockOperation {
Thread.sleep(forTimeInterval: 0.001)
}
let appearOperation = BlockOperation {
perform()
}
appearOperation.name = kAppearAction
appearOperation.addDependency(delayOperation)
return onAppear {
if !delayOperation.isFinished, !delayOperation.isExecuting {
queue.addOperation(delayOperation)
}
if !appearOperation.isFinished, !appearOperation.isExecuting {
queue.addOperation(appearOperation)
}
}
.onDisappear {
queue.operations
.first { $0.name == kAppearAction }?
.cancel()
}
}
}
For everyone still having this issue and using a NavigationView. Add this line to the root NavigationView() and it should fix the problem.
.navigationViewStyle(StackNavigationViewStyle())
From everything I have tried, this is the only thing that worked.
We don't have to do it on .onAppear(perform)
This can be done on init of View
In case someone else is in my boat, here is how I solved it for now:
struct ChannelListView: View {
#State private var searchText = ""
#State private var isNavLinkActive: Bool = false
#EnvironmentObject var channelStore: ChannelStore
#ObservedObject private var networking = Networking()
var body: some View {
NavigationView {
VStack {
SearchBar(text: $searchText)
.padding(.top, 20)
List(channelStore.allChannels) { channel in
ZStack {
NavigationLink(destination: VideoListView(channel: channel)) {
ChannelRowView(channel: channel)
}
HStack {
Spacer()
Button {
isNavLinkActive = true
// Place action/network call here
} label: {
Image(systemName: "arrow.right")
}
.foregroundColor(.gray)
}
}
.listStyle(GroupedListStyle())
}
.navigationTitle("Channels")
}
}
}
}
I've got this app:
#main
struct StoriesApp: App {
var body: some Scene {
WindowGroup {
TabView {
NavigationView {
StoriesView()
}
}
}
}
}
And here is my StoriesView:
// ISSUE
struct StoriesView: View {
#State var items: [Int] = []
var body: some View {
List {
ForEach(items, id: \.self) { id in
StoryCellView(id: id)
}
}
.onAppear(perform: onAppear)
}
private func onAppear() {
///////////////////////////////////
// Gets called 2 times on app start <--------
///////////////////////////////////
}
}
I've resolved the issue by measuring the diff time between onAppear() calls. According to my observations double calls of onAppear() happen between 0.02 and 0.45 seconds:
// SOLUTION
struct StoriesView: View {
#State var items: [Int] = []
#State private var didAppearTimeInterval: TimeInterval = 0
var body: some View {
List {
ForEach(items, id: \.self) { id in
StoryCellView(id: id)
}
}
.onAppear(perform: onAppear)
}
private func onAppear() {
if Date().timeIntervalSince1970 - didAppearTimeInterval > 0.5 {
///////////////////////////////////////
// Gets called only once in 0.5 seconds <-----------
///////////////////////////////////////
}
didAppearTimeInterval = Date().timeIntervalSince1970
}
}
In my case, I found that a few views up the hierarchy, .onAppear() (and .onDisappear()) was only being called once, as expected. I used that to post notifications that I listen to down in the views that need to take action on those events. It’s a gross hack, and I’ve verified that the bug is fixed in iOS 15b1, but Apple really needs to backport the fix.

NavigationLink deinit ObservedObject after background task is done

I have a problem with deallocating SecondVM each time when I push to new View from my ContentView and ContentVM finishes his work.
Description
After pushing to Second View, the ObservableObject is deallocated after task in ContentVM is done.
My example code bellow ContentView and `ContentVM:
final class ContentVM: ObservableObject {
#Published var title = "Start"
init() {
DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
self.title = "Changed"
}
}
}
struct ContentView: View {
#ObservedObject var vm = ContentVM()
var body: some View {
NavigationView {
VStack {
NavigationLink(destination: Second()) {
Text("Go To second")
}
Spacer()
.frame(height: 40)
Text(vm.title)
}
}
}
}
and Second and SecondVM
final class SecondVM: ObservableObject {
#Published var name: String = ""
func getName() {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
self.name = "TEST"
}
}
}
struct Second: View {
#ObservedObject var vm = SecondVM()
var body: some View {
Text(vm.name)
.padding(50)
.background(vm.name.isEmpty ? Color.white : Color.black)
.foregroundColor(Color.white)
.onAppear {
self.vm.getName()
}
}
}
As you can see on the video bellow problem appears only, when I push to Second View. The Black rectangle appears correctly and after task from ContentVM is done then, this black rectangle disappears because of deallocating SecondVM. How to avoid this kind of behaviour?
Here is solution - make link destination equatable, so when ContentView updated on own state change it does not recreate destination view (which otherwise is what happens and is origin of observed issue).
Tested with Xcode 11.5b2
// in ContentView, id can be any type but constant in this case
NavigationLink(destination: Second(id: 1).equatable()) {
Text("Go To second")
}
// SecondView
struct Second: View, Equatable {
let id: Int
static func == (lhs: Second, rhs: Second) -> Bool {
lhs.id == rhs.id
}
#ObservedObject var vm = SecondVM()
// .. other code

SwiftUI Repaint View Components on Device Rotation

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 appears. But this value doesn't change when the device orientation changes. I need to redraw all components when the user changes the device orientation.
Here‘s an idiomatic SwiftUI implementation based on a notification publisher:
struct ContentView: View {
#State var orientation = UIDevice.current.orientation
let orientationChanged = NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)
.makeConnectable()
.autoconnect()
var body: some View {
Group {
if orientation.isLandscape {
Text("LANDSCAPE")
} else {
Text("PORTRAIT")
}
}.onReceive(orientationChanged) { _ in
self.orientation = UIDevice.current.orientation
}
}
}
The output of the publisher (not used above, therefor _ as the block parameter) also contains the key "UIDeviceOrientationRotateAnimatedUserInfoKey" in its userInfo property if you need to know if the rotation should be animated.
#dfd provided two good options, I am adding a third one, which is the one I use.
In my case I subclass UIHostingController, and in function viewWillTransition, I post a custom notification.
Then, in my environment model I listen for such notification which can be then used in any view.
struct ContentView: View {
#EnvironmentObject var model: Model
var body: some View {
Group {
if model.landscape {
Text("LANDSCAPE")
} else {
Text("PORTRAIT")
}
}
}
}
In SceneDelegate.swift:
window.rootViewController = MyUIHostingController(rootView: ContentView().environmentObject(Model(isLandscape: windowScene.interfaceOrientation.isLandscape)))
My UIHostingController subclass:
extension Notification.Name {
static let my_onViewWillTransition = Notification.Name("MainUIHostingController_viewWillTransition")
}
class MyUIHostingController<Content> : UIHostingController<Content> where Content : View {
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
NotificationCenter.default.post(name: .my_onViewWillTransition, object: nil, userInfo: ["size": size])
super.viewWillTransition(to: size, with: coordinator)
}
}
And my model:
class Model: ObservableObject {
#Published var landscape: Bool = false
init(isLandscape: Bool) {
self.landscape = isLandscape // Initial value
NotificationCenter.default.addObserver(self, selector: #selector(onViewWillTransition(notification:)), name: .my_onViewWillTransition, object: nil)
}
#objc func onViewWillTransition(notification: Notification) {
guard let size = notification.userInfo?["size"] as? CGSize else { return }
landscape = size.width > size.height
}
}
There is an easier solution that the one provided by #kontiki, with no need for notifications or integration with UIKit.
In SceneDelegate.swift:
func windowScene(_ windowScene: UIWindowScene, didUpdate previousCoordinateSpace: UICoordinateSpace, interfaceOrientation previousInterfaceOrientation: UIInterfaceOrientation, traitCollection previousTraitCollection: UITraitCollection) {
model.environment.toggle()
}
In Model.swift:
final class Model: ObservableObject {
let objectWillChange = ObservableObjectPublisher()
var environment: Bool = false { willSet { objectWillChange.send() } }
}
The net effect is that the views that depend on the #EnvironmentObject model will be redrawn each time the environment changes, be it rotation, changes in size, etc.
SwiftUI 2
Here is a solution that is not using the SceneDelegate (which is missing in the new SwiftUI life cycle).
It also uses interfaceOrientation from the current window scene instead of the
UIDevice.current.orientation (which is not set when the app starts).
Here is a demo:
struct ContentView: View {
#State private var isPortrait = false
var body: some View {
Text("isPortrait: \(String(isPortrait))")
.onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in
guard let scene = UIApplication.shared.windows.first?.windowScene else { return }
self.isPortrait = scene.interfaceOrientation.isPortrait
}
}
}
It is also possible to use an extension for accessing the current window scene:
extension UIApplication {
var currentScene: UIWindowScene? {
connectedScenes
.first { $0.activationState == .foregroundActive } as? UIWindowScene
}
}
and use it like this:
guard let scene = UIApplication.shared.currentScene else { return }
If someone is also interested in the initial device orientation. I did it as follows:
Device.swift
import Combine
final class Device: ObservableObject {
#Published var isLandscape: Bool = false
}
SceneDelegate.swift
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
// created instance
let device = Device() // changed here
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
// ...
// added the instance as environment object here
let contentView = ContentView().environment(\.managedObjectContext, context).environmentObject(device)
if let windowScene = scene as? UIWindowScene {
// read the initial device orientation here
device.isLandscape = (windowScene.interfaceOrientation.isLandscape == true)
// ...
}
}
// added this function to register when the device is rotated
func windowScene(_ windowScene: UIWindowScene, didUpdate previousCoordinateSpace: UICoordinateSpace, interfaceOrientation previousInterfaceOrientation: UIInterfaceOrientation, traitCollection previousTraitCollection: UITraitCollection) {
device.isLandscape.toggle()
}
// ...
}
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 tried some of the previous answers, but had a few problems. One of the solutions would work 95% of the time but would screw up the layout every now and again. Other solutions didn't seem to be in tune with SwiftUI's way of doing things. So I came up with my own solution. You might notice that it combines features of several previous suggestions.
// Device.swift
import Combine
import UIKit
final public class Device: ObservableObject {
#Published public var isLandscape: Bool = false
public init() {}
}
// SceneDelegate.swift
import SwiftUI
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
var device = Device()
func scene(_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions) {
let contentView = ContentView()
.environmentObject(device)
if let windowScene = scene as? UIWindowScene {
// standard template generated code
// Yada Yada Yada
let size = windowScene.screen.bounds.size
device.isLandscape = size.width > size.height
}
}
// more standard template generated code
// Yada Yada Yada
func windowScene(_ windowScene: UIWindowScene,
didUpdate previousCoordinateSpace: UICoordinateSpace,
interfaceOrientation previousInterfaceOrientation: UIInterfaceOrientation,
traitCollection previousTraitCollection: UITraitCollection) {
let size = windowScene.screen.bounds.size
device.isLandscape = size.width > size.height
}
// the rest of the file
// ContentView.swift
import SwiftUI
struct ContentView: View {
#EnvironmentObject var device : Device
var body: some View {
VStack {
if self.device.isLandscape {
// Do something
} else {
// Do something else
}
}
}
}
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")
}
}
}
}
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"))
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)
}
}
The best way to do this in iOS 14:
// GlobalStates.swift
import Foundation
import SwiftUI
class GlobalStates: ObservableObject {
#Published var isLandScape: Bool = false
}
// YourAppNameApp.swift
import SwiftUI
#main
struct YourAppNameApp: App {
// GlobalStates() is an ObservableObject class
var globalStates = GlobalStates()
// Device Orientation
let orientationChanged = NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)
.makeConnectable()
.autoconnect()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(globalStates)
.onReceive(orientationChanged) { _ in
// Set the state for current device rotation
if UIDevice.current.orientation.isFlat {
// ignore orientation change
} else {
globalStates.isLandscape = UIDevice.current.orientation.isLandscape
}
}
}
}
// Now globalStates.isLandscape can be used in any view
// ContentView.swift
import SwiftUI
struct ContentView: View {
#EnvironmentObject var globalStates: GlobalStates
var body: some View {
VStack {
if globalStates.isLandscape {
// Do something
} else {
// Do something else
}
}
}
}
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)
}
}
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)
}
}
I got
"Fatal error: No ObservableObject of type SomeType found"
because I forgot to call contentView.environmentObject(orientationInfo) in SceneDelegate.swift. Here is my working version:
// OrientationInfo.swift
final class OrientationInfo: ObservableObject {
#Published var isLandscape = false
}
// SceneDelegate.swift
var orientationInfo = OrientationInfo()
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
// ...
window.rootViewController = UIHostingController(rootView: contentView.environmentObject(orientationInfo))
// ...
}
func windowScene(_ windowScene: UIWindowScene, didUpdate previousCoordinateSpace: UICoordinateSpace, interfaceOrientation previousInterfaceOrientation: UIInterfaceOrientation, traitCollection previousTraitCollection: UITraitCollection) {
orientationInfo.isLandscape = windowScene.interfaceOrientation.isLandscape
}
// YourView.swift
#EnvironmentObject var orientationInfo: OrientationInfo
var body: some View {
Group {
if orientationInfo.isLandscape {
Text("LANDSCAPE")
} else {
Text("PORTRAIT")
}
}
}
Try to use horizontalSizeClass & verticalSizeClass:
import SwiftUI
struct DemoView: View {
#Environment(\.horizontalSizeClass) var hSizeClass
#Environment(\.verticalSizeClass) var vSizeClass
var body: some View {
VStack {
if hSizeClass == .compact && vSizeClass == .regular {
VStack {
Text("Vertical View")
}
} else {
HStack {
Text("Horizontal View")
}
}
}
}
}
Found it in this tutorial. Related Apple's documentation.
Another hack to detect the change of orientation but also the splitView. (inspired by #Rocket Garden)
import SwiftUI
import Foundation
struct TopView: View {
var body: some View {
GeometryReader{
geo in
VStack{
if keepSize(geo: geo) {
ChildView()
}
}.frame(width: geo.size.width, height: geo.size.height, alignment: .center)
}.background(Color.red)
}
func keepSize(geo:GeometryProxy) -> Bool {
MyScreen.shared.width = geo.size.width
MyScreen.shared.height = geo.size.height
return true
}
}
class MyScreen:ObservableObject {
static var shared:MyScreen = MyScreen()
#Published var width:CGFloat = 0
#Published var height:CGFloat = 0
}
struct ChildView: View {
// The presence of this line also allows direct access to up-to-date UIScreen.main.bounds.size.width & .height
#StateObject var myScreen:MyScreen = MyScreen.shared
var body: some View {
VStack{
if myScreen.width > myScreen.height {
Text("Paysage")
} else {
Text("Portrait")
}
}
}
}
I have updated https://stackoverflow.com/a/62370919/7139611 to load it for the initial view and make it as work globally using Environment object.
import SwiftUI
class Orientation: ObservableObject {
#Published var isLandscape: Bool = UIDevice.current.orientation.isLandscape
}
struct ContentView: View {
#StateObject var orientation = Orientation()
#State var initialOrientationIsLandScape = false
let orientationChanged = NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)
.makeConnectable()
.autoconnect()
var body: some View {
Group {
if orientation.isLandscape {
Text("LANDSCAPE")
} else {
Text("PORTRAIT")
}
}
.onReceive(orientationChanged, perform: { _ in
if initialOrientationIsLandScape {
initialOrientationIsLandScape = false
} else {
orientation.isLandscape = UIDevice.current.orientation.isLandscape
}
})
.onAppear {
orientation.isLandscape = UIDevice.current.orientation.isLandscape
initialOrientationIsLandScape = orientation.isLandscape
}
}
}
For those wishing to manipulate some other variables/state on device rotation change, here's a solution:
struct ContentView: View {
#Environment(\.verticalSizeClass) private var verticalSizeClass
var body: some View {
VStack {
...
}
.onChange(of: verticalSizeClass, perform: { newValue in
// Update your variables/state here
}
}
}
Its important to use verticalSizeClass instead of horizontalSizeClass because the former changes when iPhone orientation is changed, but for some iPhone models the latter won't change on device rotation.
This also won't work on iPad/macOS - you'll need to use a combo of both horizontal and vertical size classes to detect rotation on those. You can see the various configurations and what values the size classes will report here under the Device size classes subheading: https://developer.apple.com/design/human-interface-guidelines/foundations/layout