mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Add animations
This commit is contained in:
parent
40bcbb1c7f
commit
4aa481ecb1
5 changed files with 175 additions and 21 deletions
|
|
@ -11,15 +11,20 @@ struct HoverButton: View {
|
||||||
var icon: String
|
var icon: String
|
||||||
var iconColor: Color = .white;
|
var iconColor: Color = .white;
|
||||||
var scale: Image.Scale = .medium
|
var scale: Image.Scale = .medium
|
||||||
|
var pressEffect: PressEffect? = nil
|
||||||
var action: () -> Void
|
var action: () -> Void
|
||||||
var contentTransition: ContentTransition = .symbolEffect;
|
var contentTransition: ContentTransition = .symbolEffect;
|
||||||
|
|
||||||
@State private var isHovering = false
|
@State private var isHovering = false
|
||||||
|
@State private var pressOffset: CGFloat = 0
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let size = CGFloat(scale == .large ? 40 : 30)
|
let size = CGFloat(scale == .large ? 40 : 30)
|
||||||
|
|
||||||
Button(action: action) {
|
Button(action: {
|
||||||
|
triggerPressEffect()
|
||||||
|
action()
|
||||||
|
}) {
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.fill(.clear)
|
.fill(.clear)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
|
|
@ -37,10 +42,31 @@ struct HoverButton: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(PlainButtonStyle())
|
.buttonStyle(PlainButtonStyle())
|
||||||
|
.offset(x: pressOffset)
|
||||||
.onHover { hovering in
|
.onHover { hovering in
|
||||||
withAnimation(.smooth(duration: 0.3)) {
|
withAnimation(.smooth(duration: 0.3)) {
|
||||||
isHovering = hovering
|
isHovering = hovering
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func triggerPressEffect() {
|
||||||
|
guard let pressEffect else { return }
|
||||||
|
|
||||||
|
switch pressEffect {
|
||||||
|
case .nudge(let amount):
|
||||||
|
withAnimation(.spring(response: 0.2, dampingFraction: 0.55)) {
|
||||||
|
pressOffset = amount
|
||||||
|
}
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.16) {
|
||||||
|
withAnimation(.spring(response: 0.28, dampingFraction: 0.72)) {
|
||||||
|
pressOffset = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PressEffect {
|
||||||
|
case nudge(CGFloat)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -600,23 +600,25 @@ private struct MinimalisticReminderDetailsView: View {
|
||||||
// MARK: - Playback Controls (Larger)
|
// MARK: - Playback Controls (Larger)
|
||||||
|
|
||||||
private var playbackControls: some View {
|
private var playbackControls: some View {
|
||||||
HStack(spacing: 16) {
|
let controls = resolvedAuxControls
|
||||||
|
|
||||||
|
return HStack(spacing: 16) {
|
||||||
if Defaults[.showShuffleAndRepeat] {
|
if Defaults[.showShuffleAndRepeat] {
|
||||||
auxButton(for: leftAuxControl)
|
auxButton(for: controls.left)
|
||||||
}
|
}
|
||||||
|
|
||||||
controlButton(icon: "backward.fill", size: 18) {
|
controlButton(icon: "backward.fill", size: 18, pressEffect: .nudge(-8)) {
|
||||||
Task { await musicManager.previousTrack() }
|
Task { await musicManager.previousTrack() }
|
||||||
}
|
}
|
||||||
|
|
||||||
playPauseButton
|
playPauseButton
|
||||||
|
|
||||||
controlButton(icon: "forward.fill", size: 18) {
|
controlButton(icon: "forward.fill", size: 18, pressEffect: .nudge(8)) {
|
||||||
Task { await musicManager.nextTrack() }
|
Task { await musicManager.nextTrack() }
|
||||||
}
|
}
|
||||||
|
|
||||||
if Defaults[.showShuffleAndRepeat] {
|
if Defaults[.showShuffleAndRepeat] {
|
||||||
auxButton(for: rightAuxControl)
|
auxButton(for: controls.right)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
|
@ -631,13 +633,22 @@ private struct MinimalisticReminderDetailsView: View {
|
||||||
frameSize: CGSize(width: 54, height: 54),
|
frameSize: CGSize(width: 54, height: 54),
|
||||||
cornerRadius: 20,
|
cornerRadius: 20,
|
||||||
foregroundColor: .white,
|
foregroundColor: .white,
|
||||||
|
pressEffect: .none,
|
||||||
|
symbolEffectStyle: .replace,
|
||||||
action: {
|
action: {
|
||||||
Task { await musicManager.togglePlay() }
|
Task { await musicManager.togglePlay() }
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func controlButton(icon: String, size: CGFloat = 18, isActive: Bool = false, action: @escaping () -> Void) -> some View {
|
private func controlButton(
|
||||||
|
icon: String,
|
||||||
|
size: CGFloat = 18,
|
||||||
|
isActive: Bool = false,
|
||||||
|
pressEffect: MinimalisticSquircircleButton.PressEffect = .none,
|
||||||
|
symbolEffect: MinimalisticSquircircleButton.SymbolEffectStyle = .none,
|
||||||
|
action: @escaping () -> Void
|
||||||
|
) -> some View {
|
||||||
MinimalisticSquircircleButton(
|
MinimalisticSquircircleButton(
|
||||||
icon: icon,
|
icon: icon,
|
||||||
fontSize: size,
|
fontSize: size,
|
||||||
|
|
@ -645,6 +656,8 @@ private struct MinimalisticReminderDetailsView: View {
|
||||||
frameSize: CGSize(width: 40, height: 40),
|
frameSize: CGSize(width: 40, height: 40),
|
||||||
cornerRadius: 16,
|
cornerRadius: 16,
|
||||||
foregroundColor: isActive ? .red : .white.opacity(0.85),
|
foregroundColor: isActive ? .red : .white.opacity(0.85),
|
||||||
|
pressEffect: pressEffect,
|
||||||
|
symbolEffectStyle: symbolEffect,
|
||||||
action: action
|
action: action
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -663,11 +676,22 @@ private struct MinimalisticReminderDetailsView: View {
|
||||||
case .mediaOutput:
|
case .mediaOutput:
|
||||||
MinimalisticMediaOutputButton()
|
MinimalisticMediaOutputButton()
|
||||||
case .lyrics:
|
case .lyrics:
|
||||||
controlButton(icon: enableLyrics ? "quote.bubble.fill" : "quote.bubble", isActive: enableLyrics) {
|
controlButton(
|
||||||
|
icon: enableLyrics ? "quote.bubble.fill" : "quote.bubble",
|
||||||
|
isActive: enableLyrics,
|
||||||
|
symbolEffect: .replace
|
||||||
|
) {
|
||||||
enableLyrics.toggle()
|
enableLyrics.toggle()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var resolvedAuxControls: (left: MusicAuxiliaryControl, right: MusicAuxiliaryControl) {
|
||||||
|
guard leftAuxControl == rightAuxControl else {
|
||||||
|
return (leftAuxControl, rightAuxControl)
|
||||||
|
}
|
||||||
|
return (leftAuxControl, MusicAuxiliaryControl.alternative(excluding: leftAuxControl))
|
||||||
|
}
|
||||||
private struct MinimalisticMediaOutputButton: View {
|
private struct MinimalisticMediaOutputButton: View {
|
||||||
@ObservedObject private var routeManager = AudioRouteManager.shared
|
@ObservedObject private var routeManager = AudioRouteManager.shared
|
||||||
@StateObject private var volumeModel = MediaOutputVolumeViewModel()
|
@StateObject private var volumeModel = MediaOutputVolumeViewModel()
|
||||||
|
|
@ -795,15 +819,41 @@ private struct MinimalisticSquircircleButton: View {
|
||||||
let frameSize: CGSize
|
let frameSize: CGSize
|
||||||
let cornerRadius: CGFloat
|
let cornerRadius: CGFloat
|
||||||
let foregroundColor: Color
|
let foregroundColor: Color
|
||||||
|
let pressEffect: PressEffect
|
||||||
|
let symbolEffectStyle: SymbolEffectStyle
|
||||||
let action: () -> Void
|
let action: () -> Void
|
||||||
|
|
||||||
@State private var isHovering = false
|
@State private var isHovering = false
|
||||||
|
@State private var pressOffset: CGFloat = 0
|
||||||
|
|
||||||
|
init(
|
||||||
|
icon: String,
|
||||||
|
fontSize: CGFloat,
|
||||||
|
fontWeight: Font.Weight,
|
||||||
|
frameSize: CGSize,
|
||||||
|
cornerRadius: CGFloat,
|
||||||
|
foregroundColor: Color,
|
||||||
|
pressEffect: PressEffect = .none,
|
||||||
|
symbolEffectStyle: SymbolEffectStyle = .none,
|
||||||
|
action: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
self.icon = icon
|
||||||
|
self.fontSize = fontSize
|
||||||
|
self.fontWeight = fontWeight
|
||||||
|
self.frameSize = frameSize
|
||||||
|
self.cornerRadius = cornerRadius
|
||||||
|
self.foregroundColor = foregroundColor
|
||||||
|
self.pressEffect = pressEffect
|
||||||
|
self.symbolEffectStyle = symbolEffectStyle
|
||||||
|
self.action = action
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: action) {
|
Button {
|
||||||
Image(systemName: icon)
|
triggerPressEffect()
|
||||||
.font(.system(size: fontSize, weight: fontWeight))
|
action()
|
||||||
.foregroundColor(foregroundColor)
|
} label: {
|
||||||
|
iconView()
|
||||||
.frame(width: frameSize.width, height: frameSize.height)
|
.frame(width: frameSize.width, height: frameSize.height)
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||||
|
|
@ -812,10 +862,62 @@ private struct MinimalisticSquircircleButton: View {
|
||||||
.contentShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
|
.contentShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
|
||||||
}
|
}
|
||||||
.buttonStyle(PlainButtonStyle())
|
.buttonStyle(PlainButtonStyle())
|
||||||
|
.offset(x: pressOffset)
|
||||||
.onHover { hovering in
|
.onHover { hovering in
|
||||||
withAnimation(.easeOut(duration: 0.18)) {
|
withAnimation(.easeOut(duration: 0.18)) {
|
||||||
isHovering = hovering
|
isHovering = hovering
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func triggerPressEffect() {
|
||||||
|
guard case let .nudge(amount) = pressEffect else { return }
|
||||||
|
|
||||||
|
withAnimation(.spring(response: 0.16, dampingFraction: 0.72)) {
|
||||||
|
pressOffset = amount
|
||||||
|
}
|
||||||
|
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) {
|
||||||
|
withAnimation(.spring(response: 0.26, dampingFraction: 0.8)) {
|
||||||
|
pressOffset = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func iconView() -> some View {
|
||||||
|
let image = Image(systemName: icon)
|
||||||
|
.font(.system(size: fontSize, weight: fontWeight))
|
||||||
|
.foregroundColor(foregroundColor)
|
||||||
|
|
||||||
|
switch symbolEffectStyle {
|
||||||
|
case .none:
|
||||||
|
image
|
||||||
|
case .replace:
|
||||||
|
if #available(macOS 14.0, *) {
|
||||||
|
image
|
||||||
|
.contentTransition(.symbolEffect(.replace))
|
||||||
|
.id(icon)
|
||||||
|
} else {
|
||||||
|
image
|
||||||
|
}
|
||||||
|
case .bounce:
|
||||||
|
if #available(macOS 14.0, *) {
|
||||||
|
image.symbolEffect(.bounce, value: icon)
|
||||||
|
} else {
|
||||||
|
image
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PressEffect {
|
||||||
|
case none
|
||||||
|
case nudge(CGFloat)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SymbolEffectStyle {
|
||||||
|
case none
|
||||||
|
case replace
|
||||||
|
case bounce
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -158,7 +158,7 @@ struct MusicControlsView: View {
|
||||||
)
|
)
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
// Lyrics shown under the author name (same font size as author) when enabled in settings
|
// Lyrics shown under the author name (same font size as author) when enabled in settings
|
||||||
if Defaults[.enableLyrics] {
|
if enableLyrics {
|
||||||
let transition = AnyTransition.asymmetric(
|
let transition = AnyTransition.asymmetric(
|
||||||
insertion: .move(edge: .bottom).combined(with: .opacity),
|
insertion: .move(edge: .bottom).combined(with: .opacity),
|
||||||
removal: .move(edge: .top).combined(with: .opacity)
|
removal: .move(edge: .top).combined(with: .opacity)
|
||||||
|
|
@ -213,21 +213,23 @@ struct MusicControlsView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private var playbackControls: some View {
|
private var playbackControls: some View {
|
||||||
HStack(spacing: 8) {
|
let controls = resolvedAuxControls
|
||||||
|
|
||||||
|
return HStack(spacing: 8) {
|
||||||
if showShuffleAndRepeat {
|
if showShuffleAndRepeat {
|
||||||
auxButton(for: leftAuxControl)
|
auxButton(for: controls.left)
|
||||||
}
|
}
|
||||||
HoverButton(icon: "backward.fill", scale: .medium) {
|
HoverButton(icon: "backward.fill", scale: .medium, pressEffect: .nudge(-6)) {
|
||||||
MusicManager.shared.previousTrack()
|
MusicManager.shared.previousTrack()
|
||||||
}
|
}
|
||||||
HoverButton(icon: musicManager.isPlaying ? "pause.fill" : "play.fill", scale: .large) {
|
HoverButton(icon: musicManager.isPlaying ? "pause.fill" : "play.fill", scale: .large) {
|
||||||
MusicManager.shared.togglePlay()
|
MusicManager.shared.togglePlay()
|
||||||
}
|
}
|
||||||
HoverButton(icon: "forward.fill", scale: .medium) {
|
HoverButton(icon: "forward.fill", scale: .medium, pressEffect: .nudge(6)) {
|
||||||
MusicManager.shared.nextTrack()
|
MusicManager.shared.nextTrack()
|
||||||
}
|
}
|
||||||
if showShuffleAndRepeat {
|
if showShuffleAndRepeat {
|
||||||
auxButton(for: rightAuxControl)
|
auxButton(for: controls.right)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
|
@ -253,6 +255,13 @@ struct MusicControlsView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var resolvedAuxControls: (left: MusicAuxiliaryControl, right: MusicAuxiliaryControl) {
|
||||||
|
guard leftAuxControl == rightAuxControl else {
|
||||||
|
return (leftAuxControl, rightAuxControl)
|
||||||
|
}
|
||||||
|
return (leftAuxControl, MusicAuxiliaryControl.alternative(excluding: leftAuxControl))
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func auxButton(for control: MusicAuxiliaryControl) -> some View {
|
private func auxButton(for control: MusicAuxiliaryControl) -> some View {
|
||||||
switch control {
|
switch control {
|
||||||
|
|
|
||||||
|
|
@ -213,9 +213,26 @@ enum MusicAuxiliaryControl: String, CaseIterable, Identifiable, Defaults.Seriali
|
||||||
case .mediaOutput:
|
case .mediaOutput:
|
||||||
return "Media Output"
|
return "Media Output"
|
||||||
case .lyrics:
|
case .lyrics:
|
||||||
return "Lyrics Toggle"
|
return "Lyrics"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var symbolName: String {
|
||||||
|
switch self {
|
||||||
|
case .shuffle:
|
||||||
|
return "shuffle"
|
||||||
|
case .repeatMode:
|
||||||
|
return "repeat"
|
||||||
|
case .mediaOutput:
|
||||||
|
return "speaker.wave.2"
|
||||||
|
case .lyrics:
|
||||||
|
return "quote.bubble"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func alternative(excluding control: MusicAuxiliaryControl) -> MusicAuxiliaryControl {
|
||||||
|
allCases.first { $0 != control } ?? .shuffle
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum TimerIconColorMode: String, CaseIterable, Identifiable, Defaults.Serializable {
|
enum TimerIconColorMode: String, CaseIterable, Identifiable, Defaults.Serializable {
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
<a href="https://github.com/Ebullioscopic/Atoll/releases">
|
<a href="https://github.com/Ebullioscopic/Atoll/releases">
|
||||||
<img src="https://img.shields.io/github/downloads/Ebullioscopic/Atoll/total?label=Downloads" alt="GitHub downloads"/>
|
<img src="https://img.shields.io/github/downloads/Ebullioscopic/Atoll/total?label=Downloads" alt="GitHub downloads"/>
|
||||||
</a>
|
</a>
|
||||||
<a href="https://discord.gg/zvjB4tJBkt">
|
<a href="https://discord.gg/PaqFkRTDF8">
|
||||||
<img src="https://img.shields.io/discord/1429481472942669896?label=Discord&logo=discord&color=7289da" alt="Discord server"/>
|
<img src="https://img.shields.io/discord/1429481472942669896?label=Discord&logo=discord&color=7289da" alt="Discord server"/>
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -30,7 +30,7 @@
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://discord.gg/zvjB4tJBkt">Join our Discord community</a>
|
<a href="https://discord.gg/PaqFkRTDF8">Join our Discord community</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
**Project rename:** DynamicIsland is now called **Atoll**. Visit the new repository at [github.com/Ebullioscopic/Atoll](https://github.com/Ebullioscopic/Atoll).
|
**Project rename:** DynamicIsland is now called **Atoll**. Visit the new repository at [github.com/Ebullioscopic/Atoll](https://github.com/Ebullioscopic/Atoll).
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue