mirror of
https://github.com/Dimillian/IceCubesApp.git
synced 2025-04-27 02:14:45 +00:00
Add flair to status action buttons (#1005)
* Add flair to status action buttons - makes tintColor viewModel independent in Action - adds isOn function to Action - moves actionButton to its own function for clarity (and help compilo) - moves the counter outside the button - creates StatusActionButtonStyle that defines how an action button behaves when tapped and toggled - adds nested SparklesView that animates sparkles when the action button is tapped Sidenote : couldn't get the "bouncy" scale effect I wanted. It wouldn't work on an iOS device, but did on the simulator. * Fix private boost action icon regression --------- Co-authored-by: Pascal Batty <pascal@zen.ly>
This commit is contained in:
parent
c4daa73932
commit
50b8c93787
2 changed files with 166 additions and 20 deletions
128
Packages/Status/Sources/Status/Row/StatusActionButtonStyle.swift
Normal file
128
Packages/Status/Sources/Status/Row/StatusActionButtonStyle.swift
Normal file
|
@ -0,0 +1,128 @@
|
||||||
|
import UIKit
|
||||||
|
import SwiftUI
|
||||||
|
import DesignSystem
|
||||||
|
|
||||||
|
extension ButtonStyle where Self == StatusActionButtonStyle {
|
||||||
|
static func statusAction(isOn: Bool = false, tintColor: Color? = nil) -> Self {
|
||||||
|
StatusActionButtonStyle(isOn: isOn, tintColor: tintColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A toggle button that slightly scales and sends particle on activation,
|
||||||
|
/// changing its foreground color to `tintColor` when toggled on
|
||||||
|
struct StatusActionButtonStyle: ButtonStyle {
|
||||||
|
var isOn: Bool
|
||||||
|
var tintColor: Color?
|
||||||
|
|
||||||
|
@State var sparklesCounter: Float = 0
|
||||||
|
|
||||||
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
|
configuration.label
|
||||||
|
.foregroundColor(isOn ? tintColor : Color(UIColor.secondaryLabel))
|
||||||
|
.animation(nil, value: isOn)
|
||||||
|
.brightness(brightness(configuration: configuration))
|
||||||
|
.animation(configuration.isPressed ? nil : .default, value: isOn)
|
||||||
|
.scaleEffect(configuration.isPressed && !isOn ? 0.8 : 1.0)
|
||||||
|
.background {
|
||||||
|
if let tint = tintColor {
|
||||||
|
SparklesView(counter: sparklesCounter, tint: tint, size: 5, velocity: 30)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: configuration.isPressed) { isPressed in
|
||||||
|
guard tintColor != nil && !isPressed && !isOn else { return }
|
||||||
|
|
||||||
|
withAnimation(.spring(response: 1, dampingFraction: 1)) {
|
||||||
|
sparklesCounter += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func brightness(configuration: Configuration) -> Double {
|
||||||
|
switch (configuration.isPressed, isOn) {
|
||||||
|
case (true, true): return 0.6
|
||||||
|
case (true, false): return 0.2
|
||||||
|
default: return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SparklesView: View, Animatable {
|
||||||
|
var counter: Float
|
||||||
|
var tint: Color
|
||||||
|
var size: CGFloat
|
||||||
|
var velocity: CGFloat
|
||||||
|
|
||||||
|
var animatableData: Float {
|
||||||
|
get { counter }
|
||||||
|
set { counter = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Cell: Identifiable {
|
||||||
|
var id: Int
|
||||||
|
var angle: CGFloat
|
||||||
|
var velocity: CGFloat
|
||||||
|
var scale: CGFloat
|
||||||
|
var alpha: CGFloat
|
||||||
|
}
|
||||||
|
|
||||||
|
@State var cells: [Cell] = []
|
||||||
|
|
||||||
|
var progress: CGFloat {
|
||||||
|
CGFloat(counter - counter.rounded(.down))
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
if progress > 0.0 {
|
||||||
|
ZStack(alignment: .center) {
|
||||||
|
ForEach(cells) { cell in
|
||||||
|
Circle()
|
||||||
|
.frame(width: size, height: size)
|
||||||
|
.foregroundColor(tint)
|
||||||
|
.opacity(cell.alpha - (progress * cell.alpha) / 3)
|
||||||
|
.scaleEffect(cell.scale - progress * cell.scale)
|
||||||
|
.offset(
|
||||||
|
x: cos(cell.angle) * progress * cell.velocity * velocity,
|
||||||
|
y: sin(cell.angle) * progress * cell.velocity * velocity
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
cells = Self.generateCells()
|
||||||
|
}
|
||||||
|
.onChange(of: counter) { [counter] newCounter in
|
||||||
|
if floor(counter) != floor(newCounter) {
|
||||||
|
cells = Self.generateCells()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func generateCells() -> [Cell] {
|
||||||
|
let cellCount = 16
|
||||||
|
let velocityRange = 0.6...1.0
|
||||||
|
let scaleRange = 0.5...1.0
|
||||||
|
let alphaRange = 0.6...1.0
|
||||||
|
|
||||||
|
let spacing = 2 * .pi/(1.618 + Double(arc4random() % 200) / 10000)
|
||||||
|
let initialSpacing = deg2rad(Double(arc4random() % 360))
|
||||||
|
return (0..<cellCount).map { index in
|
||||||
|
return Cell(
|
||||||
|
id: index,
|
||||||
|
angle: initialSpacing + spacing * Double(index),
|
||||||
|
velocity: random(within: velocityRange),
|
||||||
|
scale: random(within: scaleRange),
|
||||||
|
alpha: random(within: alphaRange)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func random(within range: ClosedRange<Double>) -> Double {
|
||||||
|
Double(arc4random()) / 0xFFFFFFFF * (range.upperBound - range.lowerBound) + range.lowerBound
|
||||||
|
}
|
||||||
|
|
||||||
|
static func deg2rad(_ number: Double) -> Double {
|
||||||
|
return number * .pi / 180
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
@ -52,16 +52,25 @@ struct StatusRowActionsView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func tintColor(viewModel: StatusRowViewModel, theme: Theme) -> Color? {
|
func tintColor(theme: Theme) -> Color? {
|
||||||
switch self {
|
switch self {
|
||||||
case .respond, .share:
|
case .respond, .share:
|
||||||
return nil
|
return nil
|
||||||
case .favorite:
|
case .favorite:
|
||||||
return viewModel.isFavorited ? .yellow : nil
|
return .yellow
|
||||||
case .bookmark:
|
case .bookmark:
|
||||||
return viewModel.isBookmarked ? .pink : nil
|
return .pink
|
||||||
case .boost:
|
case .boost:
|
||||||
return viewModel.isReblogged ? theme.tintColor : nil
|
return theme.tintColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isOn(viewModel: StatusRowViewModel) -> Bool {
|
||||||
|
switch self {
|
||||||
|
case .respond, .share: return false
|
||||||
|
case .favorite: return viewModel.isFavorited
|
||||||
|
case .bookmark: return viewModel.isBookmarked
|
||||||
|
case .boost: return viewModel.isReblogged
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -79,24 +88,10 @@ struct StatusRowActionsView: View {
|
||||||
message: Text(viewModel.status.reblog?.content.asRawText ?? viewModel.status.content.asRawText)) {
|
message: Text(viewModel.status.reblog?.content.asRawText ?? viewModel.status.content.asRawText)) {
|
||||||
Image(systemName: action.iconName(viewModel: viewModel))
|
Image(systemName: action.iconName(viewModel: viewModel))
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderless)
|
.buttonStyle(.statusAction())
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Button {
|
actionButton(action: action)
|
||||||
handleAction(action: action)
|
|
||||||
} label: {
|
|
||||||
HStack(spacing: 2) {
|
|
||||||
Image(systemName: action.iconName(viewModel: viewModel, privateBoost: privateBoost()))
|
|
||||||
.foregroundColor(action.tintColor(viewModel: viewModel, theme: theme))
|
|
||||||
if let count = action.count(viewModel: viewModel, theme: theme), !viewModel.isRemote {
|
|
||||||
Text("\(count)")
|
|
||||||
.font(.scaledFootnote)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderless)
|
|
||||||
.disabled(action == .boost &&
|
|
||||||
(viewModel.status.visibility == .direct || viewModel.status.visibility == .priv && viewModel.status.account.id != currentAccount.account?.id))
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -107,6 +102,29 @@ struct StatusRowActionsView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func actionButton(action: Action) -> some View {
|
||||||
|
HStack(spacing: 2) {
|
||||||
|
Button {
|
||||||
|
handleAction(action: action)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: action.iconName(viewModel: viewModel, privateBoost: privateBoost()))
|
||||||
|
}
|
||||||
|
.buttonStyle(
|
||||||
|
.statusAction(
|
||||||
|
isOn: action.isOn(viewModel: viewModel),
|
||||||
|
tintColor: action.tintColor(theme: theme)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.disabled(action == .boost &&
|
||||||
|
(viewModel.status.visibility == .direct || viewModel.status.visibility == .priv && viewModel.status.account.id != currentAccount.account?.id))
|
||||||
|
if let count = action.count(viewModel: viewModel, theme: theme), !viewModel.isRemote {
|
||||||
|
Text("\(count)")
|
||||||
|
.foregroundColor(Color(UIColor.secondaryLabel))
|
||||||
|
.font(.scaledFootnote)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func handleAction(action: Action) {
|
private func handleAction(action: Action) {
|
||||||
Task {
|
Task {
|
||||||
if viewModel.isRemote, viewModel.localStatusId == nil || viewModel.localStatus == nil {
|
if viewModel.isRemote, viewModel.localStatusId == nil || viewModel.localStatus == nil {
|
||||||
|
|
Loading…
Reference in a new issue