Home » Create a Spotify-Fashion Lyric View in SwiftUI | by Fred Gray | Oct, 2023

Create a Spotify-Fashion Lyric View in SwiftUI | by Fred Gray | Oct, 2023

by Icecream
0 comment

How to make an interactive lyric scrolling view in SwiftUI utilizing ScrollProxy

Photo by Matthew McBrayer on Unsplash

It was a revelation to many, myself included, when it grew to become clear that Taylor Swift wasn’t singing ‘all of the lonely Starbucks lovers’ in her tune Blank Space. Being capable of see the lyrics of the tune you’re listening to, displayed in time with the tune, permits us to be extra engaged with a tune’s messages and higher admire the artist’s lyrical skills (or lack of lyrical skills).

Spotify’s iOS app offers a card on the now-playing view that reveals the lyrics of the tune, often synced to the music. Tapping on the cardboard expands this right into a easy, full-screen view that reveals the lyrics, mechanically scrolling to the present lyrics. It’s interactive, so in the event you faucet on a lyric, the tune will skip to that lyric.

Here is my try to create this UI in SwiftUI….

The completed lyric scrolling view. Image by creator.

Creating the LyricDriver class

First, we have to create a controller to drive our view. We will create a category known as LyricDriver, and make it conform to ObservableObject, enabling our views to ‘observe’ it and pay attention out for modifications for properties with the @Published wrapper. We wish to initialise this class with a easy knowledge construction (MonitorInfo)to signify a monitor, full with lyrics damaged up by line breaks, and an array representing the synced time for every part of the lyrics. Our class will translate the lyric knowledge from this MonitorInfo into an array of LyricLine objects, every with an related time.

We want a timer to signify the time elapsed by way of the tune. We can initialise a timer empty Timer object as a property. We then create a separate operate to reinitialise the timer as a repeating timer with a 1 length, which calls our ‘selector’ operate each time it fires (i.e. each 1 second).

The methodology handed to #selector should be prefixed with @objc as the usage of the selector sample is an Objective-C framework. This methodology, fireplaceTimer(), merely increments our time property by 1. As a end result, the worth of this property might be a depend of the variety of seconds elapsed because the timer started. For our functions, we are able to keep away from having to invalidate and recreate the timer by including a situation to this methodology to solely replace time when the isPaused property is fake.

// LyricDriver.swift

import Foundation
import SwiftUI

struct MonitorInfo {

var title : String
var artist : String
var size : Int

var lyrics : String
var occasions : [Int]
}

struct LyricLine : Identifiable{

let id = UUID()
let seq : Int
let textual content : String
let keyTime : Int

}

class LyricDriver : ObservableObject{

@Published var lyricLines = [LyricLine]()
@Published var time = 0
@Published var isPaused = false

var themeColor = Color.init(uiColor: UIColor(crimson: 46.0/255, inexperienced: 81.0/255, blue: 140.0/255, alpha: 1.0))

var timer = Timer()

let monitorInfo : MonitorInfo

init(monitor: MonitorInfo){

self.monitorInfo = monitor
self.convertToLyricLine()

}

@objc personal func fireplaceTimer(){

// Called every time the 1-second Timer fires (so every second) - progress time if tune is not finsihed or paused

if !isPaused && time <= self.monitorInfo.size{
time += 1
}

}

func pause_unpause(){
isPaused.toggle()
}

func beginTimer(){
timer = Timer.scheduledTimer(timeInterval: 1.0, goal: self, selector: #selector(fireplaceTimer), consumerInfo: nil, repeats: true)
}

personal func convertToLyricLine(){

let elements = self.monitorInfo.lyrics.cut up(separator: "n")

var seq = 0
for line in elements{

self.lyricLines.append(LyricLine(seq: seq, textual content: String(line), keyTime: monitorInfo.occasions[seq]))

seq += 1
}

}
}

Creating the LyricsView

We will present the lyrics in aVStack inside a vertical ScrollView. This VStack is populated through the use of ForEvery, taking the lyricDriver’s LyricLine array as an argument.

One of the important thing options of the lyric view is that the lyrics ought to scroll in time with the music (in our case, the time property). To do that we have to programmatically scroll to subviews inside our ScrollView. In SwiftUI, we are able to use ScrollViewReader to realize this. All we have to do is place our ScrollView within the ScrollViewReader closure, which requires a ScrollViewProxy occasion (right here known as ‘proxy’) as an argument. To allow us to scroll to particular lyric strains, we have to guarantee each has the .tag view modifier, and we are able to tag every lyric line with its time property.

Using the .onChange modifier, every time a change within the time property is noticed (i.e. each second), we are able to instruct the proxy to scroll to the lyric line that has the tag matching the brand new time, if there’s one. Setting the anchor to the .middle will imply that because it scrolls, the road that’s scrolled to might be within the centre of the ScrollView. Putting this inside withAnimation creates a pleasant clean scrolling impact.

We may faucet on a lyric to scroll to it, by merely updating the time worth to the time related to a lyric line we faucet on within the .onTapGesture modifier.


//LyricsView.swift

import SwiftUI

var test_lyrics = "This is my attemptnAttemptnAttempt...nTo make an interactive lyric viewnJust like...nYou see on the Spotify appnI thought it was an attention-grabbing problem to attempt to recreate one thing comparable in SwiftUInWith ScrollProxies and Publsihed propertiesnWith GeomertyReaders and ZStacksnWe can create one thing that appears fairly coolnYou can faucet on the lyrics to skip the tune, or use the scrubbernWe can hit playnWe can hit pausenIf you utilize this instance view while listening to a tune with lyricsnIt will confuse your mindnBecause your mind will attempt to match the lyrics you hear to this nonsese scrolling by!"

var test_times = [0,1,2,4,6,8,10,13,15,18,20,22,24,26,29,32]

struct LyricsView: View {

@ObservedObject var driver = LyricDriver(monitor: MonitorInfo(title: "Lyrics That Flow", artist: "The SwiftUI Band", size: 45, lyrics: test_lyrics, occasions: test_times))

var physique: some View {

VStack(spacing: 0){

HStack{
Spacer()
VStack{
Text(driver.monitorInfo.title)
.daring()
Text(driver.monitorInfo.artist)
.foregroundColor(.grey)
.font(.caption)
}

Spacer()

}.padding(.high, 6)
.background(driver.themeColor)

ScrollViewReader{ proxy in

ScrollView(.vertical, revealsIndicators: false){
VStack {

Text("nnnn")

ForEvery(driver.lyricLines, id: .keyTime){ line in

HStack{
Text(line.textual content)

.font(.title)
.daring()
.opacity(driver.time >= line.keyTime ? 1 : 0.5)

Spacer()

}.padding(.vertical, 4)
.tag(line.keyTime)
.onTapGesture {

// By updating the time property on faucet gesture, we are able to faucet on a lyric line to scroll to it

driver.time = line.keyTime

}

}

// This simply helps create an empty house under the lyrics so the final lyric line seems nearer center of scren whens scrolled to.

Text("nnnnnnnnnn")
}

.onAppear{

driver.beginTimer()

}
}
.animation(.linear, worth: driver.time)

.onChange(of: driver.time){

print(driver.time)

withAnimation{
proxy.scrollTo(driver.time, anchor: .middle)
}

}
}
.padding()
.zIndex(1)
.masks(LinearGradient(gradient: Gradient(colours: [driver.themeColor, driver.themeColor, driver.themeColor, .clear]), startPoint: .backside, endPoint: .high))

MusicControl(driver: self.driver)
}
.background(driver.themeColor)

}
}

The MusicControl view — Play/Pause and Scrubber Bar

The MusicControl view consists of the scrubber bar and pause/play button. Image by creator.

To create the music progress bar/scrubber and the play/pause backside, we create a brand new view known as MusicControl. This view will take an occasion of our LyricDriver class as an argument and use it to initialise it as an ObservedObject. This will allow us to work together with the ‘playback’.

The play/pause button is a straightforward picture utilizing play/pause SF Symbols conditional on the LyricDriver occasion property isPaused. When we faucet it, we name the pause_unpause() methodology. In this instance, we don’t truly invalidate the Timer however merely cease including 1 to the time property when isPaused = true. This has the identical impact as ‘pausing’ the music.

The scrubber is extra complicated. The progress bar is solely two very skinny rectangle views in a ZStack. These are each initialised as the identical dimension (each inherit their accessible width from the HStack the ZStack is embedded in), however the rectangle on high of the stack will present the progress, and we merely use .scaleEffect() to scale the width of the rectangle by a multiplier (the computed property progress_scaler) that represents the proportion of the tune that has elapsed (i.e the time property divided by the monitorInfo’s size property).

The problem comes once we wish to use the scrubber to skip to completely different elements of the ‘tune’. To do that, it’s best to know the precise most dimension of the progress bar. Getting absolute sizes of a view in Swift is a job for GeometryReader, and a fast trick if we wish to discover the dimensions of an accessible house (on this case, the horizontal house within the ZStack) is so as to add an empty HStack inside a GeometryReader. We add a small circle to the ZStack, and fix a .gesture() modifier to it. We will use a DragGesture to detect the consumer scrubbing by way of the tune. In the onChanged modifier of the DragGesture, we’re calculating the ‘delta’ between every detected change, and add this to the progress_x_pos worth (a computed worth that makes use of the max width of the progress bar from our GeometryReader and the progress_scaler) to seek out the brand new width of the progress bar (newSize). By dividing newSize by the whole width, we are able to get how a lot the progress bar has progressed as a proportion of the whole bar, and might use this proportion mixed with the monitor size to calculate what the brand new time worth must be because of the scrubbing (this most likely wouldn’t be correct sufficient for an precise music participant!)

Because the time property is noticed all through our views, once we change it right here we’ll see the lyrics scroll to the related line once we replace the time worth within the scrubber.

// MusicContro.swift

import SwiftUI

struct MusicControl: View {

@ObservedObject var driver : LyricDriver
@State var progess_size : CGSize = .zero
@State var lastTranslation : CGSize = .zero

var progress_x_pos : CGFloat{

// Calculates the width of the elapsed time bar utilizing the scaler and the max width of the progress bar from the geometry reader

progess_size.width * progress_scaler

}

init(driver: LyricDriver){
self.driver = driver
}

// Proportion of the 'tune' that has elapsed....

var progress_scaler : Double{ Double(driver.time) / Double(driver.monitorInfo.size) }

// Properties/capabilities to assist show the monitor timings...

func timeDisplayFormat(time: Int) -> String{

var minutes : Int = 0
var secs : Int = 0

if time >= 60{

minutes = time / 60
secs = time % 60

} else{
minutes = 0
secs = time
}

return String(minutes) + ":" + secs.twoDigStr
}

var timeleftDisplay : String{

self.timeDisplayFormat(time: driver.monitorInfo.size - driver.time)

}

var timeDisplay : String{

self.timeDisplayFormat(time: self.driver.time)

}

var physique: some View {

VStack{
HStack{
Text(timeDisplay)
.body(width: 40)
.daring()

ZStack{

// This is the background scrubber background bar...
Rectangle()
.body(top: 2)
GeometryReader { proxy in

HStack {}
// This empty HStack would be the similar width because the scrubber bar
.onAppear {
self.progess_size = proxy.dimension

}
}

//This is the progress bar to point out the time elapsed towards the whole time

Rectangle()
.body(top: 2)
.scaleEffect(x: self.progress_scaler, anchor: .main)
.foregroundColor(.inexperienced)
.animation(.default, worth: progress_scaler)

// This Circle is on the finish of the progress bar and can detect drag gestures for scrubbing

Circle()
.body(width: 12, top: 12)
.foregroundColor(.inexperienced)
.place(CGPoint(x: self.progress_x_pos, y: self.progess_size.top/2 ))
.animation(.default, worth: self.progress_x_pos)
.gesture(
DragGesture()
.onChanged({

let delta = $0.translation.width - self.lastTranslation.width
var newSize = self.progress_x_pos + delta
driver.time = Int((newSize/self.progess_size.width) * Double(driver.monitorInfo.size))
self.lastTranslation = $0.translation

})
.onEnded{_ in
self.lastTranslation = .zero
}
)
}

Text(timeleftDisplay)
.body(width: 40)
.daring()

}
.padding(.horizontal, 20)

HStack{
Spacer()
Image(systemName: driver.isPaused ? "play.circle.fill" : "pause.circle.fill")
.font(.system(dimension: 70))
.onTapGesture {
driver.pause_unpause()
}
Spacer()
}
}
.body(top: 100)
.background(
Rectangle()
.foregroundColor(driver.themeColor)
.blur(radius: 5)
)
}
}

extension Int{

// Returns string model of Int with prefix of '0' if beneath 10

var twoDigStr : String{
let intStr = String(self)
if intStr.depend == 1{
return "0" + intStr
} else {
return intStr
}
}

You may also like

Leave a Comment