swiftuidevice-orientationios16swiftui-view

SwiftUI animated background view adjust width according to size changes (orientation)


I have an animated background view which is a struct (the view) that instantiates a class. It represents stars animating and moving from right to left on the screen. The view (StarsView) is given a width on creation. However, on size changes (like orientating from portrait to landscape), the Stars (Starfield) keep the initial width. How do I reinit the Starfield with the correct width on size changes?

My view is wrapped in a special responsive View so I have access to all kinds of properties regarding device and size:

/// Responsive UI Properties
struct UIProperties: Equatable {
    var isLandscape: Bool
    var isiPad: Bool
    var isSplit: Bool
    // if the app is reduced more than 1/3 in split mode on iPads
    var isMaxSplit: Bool
    var isAdoptable: Bool
    var size: CGSize
}

// MARK: - Responsive View
/// Custom Responsive View which will give useful properties for creating adaptive UI
struct ResponsiveView<Content: View>: View {
    var content: (UIProperties) -> Content
    
    var body: some View {
        GeometryReader { proxy in
            let size = proxy.size
            let isLandscape = size.width > size.height
            let isiPad = UIDevice.current.userInterfaceIdiom == .pad
            let isSplit = isSplitscreen()
            let isMaxSplit = isSplit && size.width < 400
            
            // iPad vertical orientation; hide SideBar completely
            // Horizontal showing SideBar for 0.75 fraction
            let isAdoptable = isiPad && (isLandscape ? !isMaxSplit : !isSplit)
            
            let properties = UIProperties(isLandscape: isLandscape, isiPad: isiPad, isSplit: isSplit, isMaxSplit: isMaxSplit, isAdoptable: isAdoptable, size: size)

            content(properties)
                .frame(width: size.width, height: size.height)
        }
    }
    
    init(@ViewBuilder content: @escaping (UIProperties) -> Content) {
        self.content = content
    }
    
    // MARK: - Simple way to determine if the app is in split mode
    private func isSplitscreen() -> Bool {
        guard let screen = UIApplication.shared.connectedScenes.first as? UIWindowScene else { return false }
        return screen.windows.first?.frame.size != screen.screen.bounds.size
    }
}

ContentView and the View that hast the StarsView as a background:

struct ContentView: View {
(...)    
    var body: some View {
        // Responsive View for adaptable layout orientations and devices
        ResponsiveView { properties in
                  GamesView(dataController: dataController, properties: properties)
                }
                .accentColor(.white)
    }
}

struct GamesView: View {
    // MARK: - Properties & State
    @StateObject var gamesViewModel: GamesViewModel
    var uiProperties: UIProperties
    (...)    

    // MARK: - Body
    var body: some View {
            ZStack (alignment: .bottom){
                background
                    .ignoresSafeArea()
                
                StarsView(uiProperties.size.width)
                    .ignoresSafeArea()
                    .opacity(starOpacity)
                
                VStack {
                    ScrollView {
                        gamiqText
                        
                        if gamesViewModel.filteredGames().count == 0 {
                            Spacer()
                                .frame(height: 88)
                            
                            noGamesView
                        } else {
                            horizontalList
                        }
                    }
                }
                .edgesIgnoringSafeArea([.leading, .trailing])
                
                utilitiesBar
            }
            (...)
}

The StarsView itself:

struct StarsView: View {
    var width: CGFloat
    
    @State var starField: StarField
    @State var meteorShower = MeteorShower()
    
    var body: some View {
        TimelineView(.animation) { timeline in
            Canvas { context, size in
                let timeInterval = timeline.date.timeIntervalSince1970
                
                // Update Starfield
                starField.update(date: timeline.date)
                
                // Update meteors before blurring!
                meteorShower.update(date: timeline.date, size: size)
                
                //let rightColors = [.clear, Color(red: 0.8, green: 1, blue: 1), .white]
                let rightColors = [Color.clear, .yellow.opacity(0.5), .orange.opacity(0.6), .white]
                let leftColors = Array(rightColors.reversed())
                                
                // Add Blur to StarField
                context.addFilter(.blur(radius: 0.3))
                
                for (index, star) in starField.stars.enumerated() {
                    let path = Path(ellipseIn: CGRect(x: star.x, y: star.y, width: star.size, height: star.size))
                    
                    if star.flickerInterval == 0 {
                        //Flashing (smaller) star
                        var flashLevel = sin(Double(index) + timeInterval * 4) //Sin varies between +1 and -1
                        flashLevel = abs(flashLevel)
                        flashLevel /= 2
                        context.opacity = 0.5 + flashLevel //values will always be between 0.5 and 1
                    } else {
                        //Blooming (bigger) star
                        var flashLevel = sin(Double(index) + timeInterval) //Sin varies between +1 and -1
                        //flashLevel = -1 to 1
                        //if we multiply that with the flickerInterval, which is 3 to 20
                        //Then: flashlevel will be -3 to 3 on the low ends and up to -20 to 20 on the high end
                        //Then: take away flashLevel - 1 will get us
                        // (19) -39 to 1 for opacity on the hight end, and
                        // (2) -5 to 1 on the low end
                        //SO: long time not visible bloom, then shortly visible (1)
                        flashLevel *= star.flickerInterval
                        flashLevel -= star.flickerInterval - 1
                        
                        //If flashlevel > 0 will add blurred (bloom) circles around (behind) our star
                        if flashLevel > 0 {
                            var contextCopy = context
                            contextCopy.opacity = flashLevel
                            contextCopy.addFilter(.blur(radius: 3))
                            
                            contextCopy.fill(path, with: .color(white: 1))
                            contextCopy.fill(path, with: .color(white: 1))
                            contextCopy.fill(path, with: .color(white: 1))
                        }
                        
                        context.opacity = 1 //reset
                    }
                    
                    //color variations and actual stars drawing (paths)
                    if index.isMultiple(of: 5) {
                        context.fill(path, with: .color(.orange.opacity(0.55)))
                    } else if index.isMultiple(of: 7) {
                        context.fill(path, with: .color(.yellow.opacity(0.85)))
                    } else {
                        context.fill(path, with: .color(white: 1))
                    }
                }
            }
        }
        .ignoresSafeArea()
        .mask( //Fade out stars near the bottom
            LinearGradient(colors: [.white, .clear], startPoint: .top, endPoint: .bottom)
        )
    }
    
    init(_ width: CGFloat) {
        self.width = width
        _starField = State(wrappedValue: StarField(width))
    }
}

And last, but not least, The StarField class:

class StarField {
    var width: CGFloat
    var stars = [Star]()
    let leftEdge = -50.0
    let rightEdge: CGFloat
    var lastUpdate = Date.now
    
    init(_ width: CGFloat) {
        self.width = width
        let numberOfStars = width > 500 ? 400 : 200
        rightEdge = width + 50
        
        for _ in 1...numberOfStars {
            let x = Double.random(in: leftEdge...rightEdge)
            let y = Double.random(in: 0...600)
            let size = Double.random(in: 1...3)
            let star = Star(x: x, y: y, size: size)
            stars.append(star)
        }
    }
    
    func update(date: Date) {
        let delta = date.timeIntervalSince1970 - lastUpdate.timeIntervalSince1970
        
        for star in stars {
            star.x -= delta * 2
            
            if star.x < leftEdge {
                star.x = rightEdge
            }
            
            lastUpdate = date
        }
    }
}
uiProperties.size.width

gets updated properly on changes (landscape, portrait, etc)

EDIT: One last note; often when coming from the background the Stars are all on a one pixel vertical line. I think it is related to the same problem.

Any nudges and tips welcome! Thanks!


Solution

  • Hmmmmmmm, I was doing things way too complicated. Turns out a simple solution is all it takes. I added an onChange call to the StarsView that reinitialises the Starfield with a new width whenever the width changes:

           // Reinitialise StarField when width changes!
            .onChange(of: width) { newWidth in
                starField = StarField(newWidth)
            }
    

    My only question is whether the old StarField instance gets destroyed. I guess so, There's nothing referencing it anymore?