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!
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?