SwiftUI Blog

Mastering SwiftUI: Your Guide to Building Beautiful, Intuitive Apps.

Timer, shape, and player

Creating a Dynamic Christmas Tree with Decorations and Light Effects

In this blog, we’ll learn:

  • How to create geometric triangle.
  • How to create a tree using triangles.
  • How to use a timer to change the color of the Christmas tree decorations.
  • How to play music.

Shortly, we’ll create this (tree.mov):

Create the triangle

struct Triangle: Shape {
    func path(in rect: CGRect) -> Path {
        var path = Path()

        path.move(to: CGPoint(x: rect.midX, y: rect.minY)) // A
        path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY)) // AB
        path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) // BC
        path.addLine(to: CGPoint(x: rect.midX, y: rect.minY)) // CA

        return path
    }
}

First, the triangle implements the Shape protocol and creates a triangular shape by drawing in this way (considering that we pass a rectangle/frame):

Remember that the coordinates start from the top of the screen.

Create the tree

We want to create a tree by overlapping triangles, similar to this Christmas tree

struct ContentView: View {
    var body: some View {
        ZStack {
            VStack(spacing: 0) {
                Image(systemName: "star")
                    .resizable()
                    .frame(width: 100, height: 100)
                    .foregroundStyle(.yellow)
                    .offset(y: 20)
                HStack(alignment: .bottom) {
                 Circle().background().foregroundStyle(.yellow).frame(width: 20, height: 20)
                    .offset(x: 10)
                    Triangle()
                        .frame(width: 260, height: 300)
                        .foregroundStyle(.green)
                    Circle().background().foregroundStyle(.yellow).frame(width: 20, height: 20)
                        .offset(x: -10)
                }
            }.offset(y: -50)
            HStack(alignment: .bottom) {
                Circle().background().foregroundStyle(.yellow).frame(width: 20, height: 20)
                    .offset(x: 10)
                Triangle()
                    .frame(width: 280, height: 300)
                    .foregroundStyle(.green)
                
                Circle().background().foregroundStyle(.yellow).frame(width: 20, height: 20)
                    .offset(x: -10)
            }.offset(y: 60)
            
            HStack(alignment: .bottom) {
                Circle().background().foregroundStyle(.yellow).frame(width: 20, height: 20)
                    .offset(x: 10)
                Triangle()
                    .frame(width: 300, height: 300)
                    .foregroundStyle(.green)
                
                Circle().background().foregroundStyle(.yellow).frame(width: 20, height: 20)
                    .offset(x: -10)
            }.offset(y: 120)
            HStack(alignment: .bottom) {
                Circle().background().foregroundStyle(.yellow).frame(width: 20, height: 20)
                    .offset(x: 10)
                Triangle()
                    .frame(width: 320, height: 300)
                    .foregroundStyle(.green)
                
                Circle().background().foregroundStyle(.yellow).frame(width: 20, height: 20)
                    .offset(x: -10)
            }.offset(y: 180)
            Rectangle()
                .frame(width: 20, height: 60)
                .foregroundStyle(.brown)
                .offset(y: 360)
        }
    }
}

(Don’t worry, I’ll provide the GitHub link at the end.)

Let’s explore how this code works:

  • We use a ZStack to overlap the triangles.
  • We start from the bottom by adding the trunk.
  • Begin with the first triangle within an HStack, accompanied by two yellow circles representing the decorations. Everything is aligned at the bottom because we want to move the circles near the base of the triangle. We use the offset to position the circles, as well as to position the triangle.
  • The other triangles are added by changing the offset and the size of each triangle.
  • At the end (actually at the beginning of the code), we have a VStack that contains the first triangle with a star on top.

Now we have this:

It’s not quite what we want yet, and the code could be improved. Let’s do some refactoring.

Use the timer

First, we create a view that contains a triangle with two decorations:

struct TriangleWidthDecoration: View {
    var triangleWidth: Double = 300
    var leftColorDecoration: Color = .yellow
    var rightColorDecoration: Color = .yellow
    let triangleHeight: Double = 300
    
    var body: some View {
        HStack(alignment: .bottom) {
            Image(systemName: "snowflake")
                .foregroundStyle(leftColorDecoration)
            .frame(width: 20, height: 20)
                .offset(x: 10)
            Triangle()
                .frame(width: triangleWidth, height: triangleHeight)
                .foregroundStyle(.green)
            Image(systemName: "snowflake")
                .foregroundStyle(rightColorDecoration)
                .frame(width: 20, height: 20)
                .offset(x: -10)
        }
    }
}

So, we define four properties:

  • triangleWidth: the lenght of the base
  • triangleHeight: the constant height of the triangle
  • leftColorDecoration: the color of the decorarion on the left side
  • rightColorDecoration: the color of the decoration on the right side

Note that now the decoration is a snowflake.

Now the main code is:

var body: some View {
        ZStack {
            VStack(spacing: 0) {
                Image(systemName: "star")
                    .resizable()
                    .frame(width: 100, height: 100)
                    .foregroundStyle(.yellow)
                    .offset(y: 20)
                TriangleWidthDecoration(triangleWidth: 260, leftColorDecoration: .yellow, rightColorDecoration: .yellow)
                
            }.offset(y: -50)
            ForEach(Array(widths.enumerated()), id: \.offset) { index, leaf in
                TriangleWidthDecoration(triangleWidth: Double(leaf), leftColorDecoration: .yellow, rightColorDecoration: .yellow)
                    .offset(y: 60 * Double( index + 1))
            }
            
            Rectangle()
                .frame(width: 20, height: 60)
                .foregroundStyle(.brown)
                .offset(y: 360)
        }
    }

Apart from the triangle on the top, the others are drawn using a ForEach loop, where for each triangle, the offset is calculated using the index (pay close attention to the ForEach definition). The width is obtained from this array:

var widths = [280, 300, 320]

The work is not yet complete because we want to achieve the effect of real Christmas lights, with random colors. Therefore, we add:

var widths = [280, 300, 320]
    let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
    var colors = [Color.red, Color.purple, Color.blue, Color.orange, Color.gray, Color.yellow, Color.cyan, Color.mint, Color.pink]
    var stars = ["star", "star.fill"]
    @State var indexStar = 0
    @State var currentColors = [Color]( repeating: .gray, count: 8 )

The timer is used to change the color every second.

  • The colors: a list of colors that can be used for the decorations.
  • The stars: contains the possible images for the star.
  • The indexStar: contains the index of the current image used for the star.
  • The currentColors: contains the current colors (note that there are eight, four per side).

The timer run in the main thread because work in the GUI side and with the .common option because work alongside with common events.

The code now is:

var body: some View {
        ZStack {
            Color.black.frame(maxWidth: .infinity, maxHeight: .infinity).opacity(0.9).edgesIgnoringSafeArea(.all)
            VStack(spacing: 0) {
                Image(systemName: stars[indexStar])
                    .resizable()
                    .frame(width: 100, height: 100)
                    .foregroundStyle(.yellow)
                    .offset(y: 20)
                TriangleWidthDecoration(triangleWidth: 260, leftColorDecoration: currentColors[3], rightColorDecoration: currentColors[7])
                
            }.offset(y: -50)
            ForEach(Array(widths.enumerated()), id: \.offset) { index, leaf in
                TriangleWidthDecoration(triangleWidth: Double(leaf), leftColorDecoration: currentColors[index], rightColorDecoration: currentColors[index * 2])
                    .offset(y: 60 * Double( index + 1))
            }
            
            Rectangle()
                .frame(width: 20, height: 60)
                .foregroundStyle(.brown)
                .offset(y: 360)
        }.onReceive(timer) { _ in
            shuffleColor()
            self.indexStar = (self.indexStar + 1) % 2
        }
    }

The color for the decorations is taken from the currentColors array, using the index. Every second, the colors are remixed using the shuffleColor function, and the image of the star is changed because the indexStar is updated (there are only two possible values: 0 or 1).

The timer event is captured using the onReceive with the timer (we use the underscore because we don’t need the timer’s value).

The shuffle functions is:

func shuffleColor() {
        for i in 0..<currentColors.count {
            currentColors[i] = colors[Int.random(in: 0..<9)]
        }
    }

Finally, at the beginning of the ZStack, a dark background is set.

Adding music

First, we need to create a player. We’ll do this by using the new Observable class annotation, which also works in the latest iOS 17. Some might wonder why I’m focusing on the latest features. The goal of my post is to update current iOS developers on the latest advancements and to help new developers, who usually start by using the most recent tools, create code effectively.

So, let’s take a look at the player:

import AVFoundation

@Observable
class AudioPlayerViewModel {
  var audioPlayer: AVAudioPlayer?

  var isPlaying = false

  init() {
    if let sound = Bundle.main.path(forResource: "silent", ofType: "mp3") {
      do {
        self.audioPlayer = try AVAudioPlayer(contentsOf: URL(fileURLWithPath: sound))
      } catch {
        print("AVAudioPlayer can't be instantiated.")
      }
    } else {
      print("Audio file not found.")
    }
  }

  func playOrPause() {
    guard let player = audioPlayer else { return }

    if player.isPlaying {
      player.pause()
      isPlaying = false
    } else {
      player.play()
      isPlaying = true
    }
  }
}

First, we import the AVFoundation framework, as described in the official documentation at https://developer.apple.com/av-foundation/. As we can read there: “AVFoundation is the full featured framework for working with time-based audiovisual media on iOS, macOS, watchOS and tvOS. Using AVFoundation, you can easily play, create, and edit QuickTime movies and MPEG-4 files, play HLS streams, and build powerful media functionality into your apps”.

In the class, the player is defined as an optional, because it could be null for some reason. After that, we retrieve the path to the file we want to play and pass it to the player’s constructor. If something goes wrong, we’ll see an error message.

The method playOrPause, plays or stops the music depending the current playing state.

Note: Regarding the audio file, it has not been added to the assets, but rather to the project structure. (If oyu have the device in silent mode, you will not listen nothing).

Now add the player to the code:

.
.
@State var audioPlayerViewModel = AudioPlayerViewModel()
    
    var body: some View {
        ZStack {
            Color.black.frame(maxWidth: .infinity, maxHeight: .infinity).opacity(0.9).edgesIgnoringSafeArea(.all)
            VStack(spacing: 0) {
                Image(systemName: stars[indexStar])
                    .resizable()
                    .frame(width: 100, height: 100)
                    .foregroundStyle(.yellow)
                    .offset(y: 20)
                TriangleWidthDecoration(triangleWidth: 260, leftColorDecoration: currentColors[3], rightColorDecoration: currentColors[7])
                
            }.offset(y: -50)
            ForEach(Array(widths.enumerated()), id: \.offset) { index, leaf in
                TriangleWidthDecoration(triangleWidth: Double(leaf), leftColorDecoration: currentColors[index], rightColorDecoration: currentColors[index * 2])
                    .offset(y: 60 * Double( index + 1))
            }
            
            Rectangle()
                .frame(width: 20, height: 60)
                .foregroundStyle(.brown)
                .offset(y: 360)
        }.onReceive(timer) { _ in
            shuffleColor()
            self.indexStar = (self.indexStar + 1) % 2
        }.onAppear {
            audioPlayerViewModel.playOrPause()
        }.onTapGesture {
            audioPlayerViewModel.playOrPause()
        }
    }
    
    func shuffleColor() {
        for i in 0..<currentColors.count {
            currentColors[i] = colors[Int.random(in: 0..<9)]
        }
    }
}

The music starts when the tree appears. You can play or stop it by tapping on the tree.

You can obtain a free MP3 file from this source: https://www.freemusicpublicdomain.com/royalty-free-christmas-music/

The code is here

https://github.com/niqt/swift/tree/master/ChristmasTree

Holy Christmas.

Leave a Reply

Your email address will not be published. Required fields are marked *