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….
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.swiftimport 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.swiftimport 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
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.swiftimport 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
}
}