-
-
Save indyfromoz/df6e9897894c331bbb33db34d9cf0cd2 to your computer and use it in GitHub Desktop.
Sample code replicating the album details / track list screen from Apple Music
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
struct AMAlbumDetailsView: View { | |
let albumArt: Image | |
let title: String | |
let artist: String | |
let genre: String | |
let releaseYear: String | |
let quality: String | |
let tracks: [String] | |
@State private var playingTrack: String? | |
@State private var visibleBottomSpace: Bool = false | |
var body: some View { | |
List { | |
Section { | |
headerView | |
} | |
Section { | |
trackList | |
} | |
Section { | |
bottomDetailsStack | |
} | |
.listSectionSeparator(.hidden) | |
Section { | |
moreContentSection | |
.padding(.bottom) | |
.onGeometryChange(for: Bool.self) { geo in | |
geo.frame(in: .global).maxY < UIScreen.main.bounds.height | |
} action: { newValue in | |
visibleBottomSpace = newValue | |
} | |
} | |
.listSectionSeparator(.hidden) | |
.listRowBackground(Color(uiColor: .secondarySystemBackground)) | |
} | |
.listStyle(.plain) | |
.background { | |
Color(uiColor: visibleBottomSpace ? .secondarySystemBackground : .systemBackground) | |
} | |
.ignoresSafeArea(edges: .bottom) | |
} | |
private var headerView: some View { | |
VStack { | |
albumArt | |
.resizable() | |
.scaledToFill() | |
.clipShape(RoundedRectangle(cornerRadius: 10)) | |
.shadow(radius: 3, y: 3) | |
.padding(.horizontal, 40) | |
topDetailsStack | |
actionButtonsStack | |
} | |
.alignmentGuide(.listRowSeparatorLeading) { _ in | |
return 0 | |
} | |
} | |
private var topDetailsStack: some View { | |
VStack(spacing: 3) { | |
Text(title) | |
.font(.title2.bold()) | |
Text(artist) | |
.font(.title2) | |
.foregroundStyle(.pink) | |
Text("\(genre) • \(releaseYear) • \(quality)") | |
.font(.caption.bold()) | |
.foregroundStyle(.gray) | |
} | |
} | |
private var actionButtonsStack: some View { | |
HStack(spacing: 15) { | |
actionButton(title: "Play", symbolName: "play.fill") { | |
print("Play album") | |
playingTrack = tracks.first | |
} | |
actionButton(title: "Shuffle", symbolName: "shuffle") { | |
print("Shuffle album") | |
playingTrack = tracks.randomElement() | |
} | |
} | |
.padding(.vertical) | |
} | |
private func actionButton( | |
title: LocalizedStringKey, | |
symbolName: String, | |
completion: @escaping () -> Void | |
) -> some View { | |
Button(action: completion) { | |
HStack(spacing: 4) { | |
Image(systemName: symbolName) | |
Text(title) | |
} | |
.font(.headline) | |
.foregroundStyle(.pink) | |
.frame(maxWidth: .infinity) | |
.padding(.vertical, 12) | |
.background(Color(uiColor: .secondarySystemBackground)) | |
.clipShape(RoundedRectangle(cornerRadius: 10)) | |
} | |
.buttonStyle(.plain) | |
} | |
private var trackList: some View { | |
ForEach(Array(tracks.enumerated()), id: \.element) { (index, trackName) in | |
Button { | |
// Play the song | |
print("Play: \(trackName)") | |
playingTrack = trackName | |
} label: { | |
HStack { | |
Text(trackName) | |
Spacer() | |
Image(systemName: "ellipsis") | |
} | |
.padding(.leading, 25) | |
.overlay(alignment: .leading) { | |
if playingTrack == trackName { | |
HStack(spacing: 2) { | |
ForEach(0...3, id: \.self) { _ in | |
Capsule() | |
.frame(width: 2, height: CGFloat.random(in: 8...20)) | |
.foregroundStyle(.gray) | |
} | |
} | |
} else { | |
Text("\(index + 1)") | |
.foregroundStyle(.gray) | |
} | |
} | |
.alignmentGuide(.listRowSeparatorLeading) { _ in | |
return (index == tracks.count - 1) ? 0 : 25 | |
} | |
} | |
.contextMenu { | |
Button { | |
// Show credits | |
print("Show credits: \(trackName)") | |
} label: { | |
Text("Show credits") | |
} | |
} preview: { | |
TrackContextMenuPreview( | |
albumArt: albumArt, | |
title: trackName, | |
artist: artist, | |
album: title, | |
releaseYear: releaseYear | |
) | |
} | |
} | |
} | |
private var bottomDetailsStack: some View { | |
VStack(alignment: .leading) { | |
Text("April 20, 2021") | |
Text("\(tracks.count) tracks, 32 minutes") | |
Text("© 2021 P NATION, under license to Dreamus") | |
} | |
.font(.subheadline) | |
.foregroundStyle(.gray) | |
} | |
private var moreContentSection: some View { | |
VStack(spacing: 20) { | |
additionalContentSection(title: "More titles from \(artist)") | |
additionalContentSection(title: "You might also like") | |
} | |
} | |
private func additionalContentSection(title: String) -> some View { | |
VStack(alignment: .leading) { | |
HStack { | |
Text(title) | |
.font(.title2.bold()) | |
Image(systemName: "chevron.right") | |
.font(.body.bold()) | |
} | |
ScrollView(.horizontal, showsIndicators: false) { | |
HStack(spacing: 10) { | |
ForEach(0...10, id: \.self) { _ in | |
contentCard(image: albumArt, title: "Example", year: "2024") | |
} | |
} | |
} | |
.scrollClipDisabled() | |
} | |
} | |
private func contentCard(image: Image, title: String, year: String) -> some View { | |
VStack(alignment: .leading) { | |
image | |
.resizable() | |
.scaledToFill() | |
.frame(width: 150, height: 150) | |
.clipShape(RoundedRectangle(cornerRadius: 5)) | |
Text(title) | |
.font(.callout) | |
.lineLimit(1) | |
Text(year) | |
.font(.callout) | |
.foregroundStyle(.gray) | |
} | |
} | |
} | |
struct TrackContextMenuPreview: View { | |
let albumArt: Image | |
let title: String | |
let artist: String | |
let album: String | |
let releaseYear: String | |
var body: some View { | |
HStack(spacing: 20) { | |
albumArt | |
.resizable() | |
.scaledToFill() | |
.frame(width: 100, height: 100) | |
.clipShape(RoundedRectangle(cornerRadius: 10)) | |
VStack(alignment: .leading) { | |
Text(title) | |
.font(.headline) | |
.lineLimit(1) | |
Text(artist) | |
.font(.caption) | |
.foregroundStyle(.gray) | |
.lineLimit(1) | |
Text("\(album) • \(releaseYear)") | |
.font(.caption) | |
.foregroundStyle(.gray) | |
.lineLimit(1) | |
} | |
.frame(minWidth: 150, alignment: .leading) | |
} | |
.padding(15) | |
} | |
} | |
#Preview { | |
AMAlbumDetailsView( | |
albumArt: Image("albumImage"), | |
title: "Dry Flower", | |
artist: "PENOMECO", | |
genre: "Hip-hop/Rap", | |
releaseYear: "2021", | |
quality: "Lossless audio", | |
tracks: [ | |
"Rain Drop", | |
"You Up", | |
"JAJA", | |
"Actually, Pt. 2", | |
"Interlude", | |
"Remember Me (Hoody)", | |
"Better (feat. Kid Milli)", | |
"Change (feat. sogumm)", | |
"Hotel Lobby (feat. Verbal Jint)", | |
"Insomnia" | |
] | |
) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment