0

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!

1
  • Anyone? Any tips? Commented Apr 11, 2023 at 9:02

1 Answer 1

0

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?

Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.