Skip to content

Commit 300c908

Browse files
committed
feat: add copy animation
1 parent df2f738 commit 300c908

3 files changed

Lines changed: 109 additions & 8 deletions

File tree

Sources/MarkdownView/Components/CodeView/CodeView.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,11 @@ import Litext
178178
lazy var scrollView: UIScrollView = .init()
179179
lazy var languageLabel: UILabel = .init()
180180
lazy var textView: LTXLabel = .init()
181-
lazy var copyButton: UIButton = .init()
181+
lazy var copyButton = SymbolActionView(
182+
systemName: "doc.on.doc",
183+
effect: .confirmation,
184+
config: .init(scale: .small)
185+
)
182186
lazy var previewButton: UIButton = .init()
183187
lazy var lineNumberView: LineNumberView = .init()
184188

@@ -243,7 +247,7 @@ import Litext
243247
)
244248
}
245249

246-
@objc func handleCopy(_: UIButton) {
250+
func handleCopy() {
247251
UIPasteboard.general.string = content
248252
#if !os(visionOS)
249253
UINotificationFeedbackGenerator().notificationOccurred(.success)

Sources/MarkdownView/Components/CodeView/CodeViewConfiguration.swift

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -111,13 +111,10 @@ enum CodeViewConfiguration {
111111
}
112112

113113
private func setupCopyButton() {
114-
let copyImage = UIImage(
115-
systemName: "doc.on.doc",
116-
withConfiguration: UIImage.SymbolConfiguration(scale: .small)
117-
)
118-
copyButton.setImage(copyImage, for: .normal)
119114
copyButton.tintColor = theme.colors.body
120-
copyButton.addTarget(self, action: #selector(handleCopy(_:)), for: .touchUpInside)
115+
copyButton.onTap { [weak self] in
116+
self?.handleCopy()
117+
}
121118
barView.addSubview(copyButton)
122119
}
123120

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
#if canImport(UIKit)
2+
import UIKit
3+
4+
/// A tappable UIImageView with built-in SF Symbol effect support.
5+
///
6+
/// Supports three common patterns:
7+
/// - **Confirmation**: Replaces to a checkmark and back (copy, save)
8+
/// - **Bounce**: Bounces the icon in place (share)
9+
/// - **State toggle**: Replaces between two icons with optional ongoing effect (speaker)
10+
class SymbolActionView: UIImageView {
11+
12+
enum Effect {
13+
/// Replaces icon with checkmark, then replaces back after completion.
14+
case confirmation
15+
/// Bounces the icon upward.
16+
case bounce
17+
/// No automatic effect; caller manages transitions manually.
18+
case none
19+
}
20+
21+
static let defaultConfig = UIImage.SymbolConfiguration(pointSize: 16, weight: .medium)
22+
23+
private let symbolName: String
24+
private let symbolConfig: UIImage.SymbolConfiguration
25+
private let effect: Effect
26+
private var action: (() -> Void)?
27+
28+
init(
29+
systemName: String,
30+
effect: Effect = .none,
31+
config: UIImage.SymbolConfiguration = SymbolActionView.defaultConfig
32+
) {
33+
self.symbolName = systemName
34+
self.symbolConfig = config
35+
self.effect = effect
36+
super.init(image: UIImage(systemName: systemName, withConfiguration: config))
37+
38+
tintColor = UIColor(white: 0.6, alpha: 1)
39+
contentMode = .center
40+
isUserInteractionEnabled = true
41+
42+
addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleTap)))
43+
}
44+
45+
@available(*, unavailable)
46+
required init?(coder: NSCoder) {
47+
fatalError("init(coder:) has not been implemented")
48+
}
49+
50+
func onTap(_ handler: @escaping () -> Void) {
51+
self.action = handler
52+
}
53+
54+
@objc private func handleTap() {
55+
action?()
56+
57+
if #available(iOS 17.0, visionOS 1.0, *) {
58+
switch effect {
59+
case .confirmation:
60+
let checkImage = UIImage(systemName: "checkmark", withConfiguration: symbolConfig)!
61+
setSymbolImage(checkImage, contentTransition: .replace) { [weak self] context in
62+
guard let self,
63+
let imageView = context.sender as? UIImageView,
64+
context.isFinished else { return }
65+
let originalImage = UIImage(
66+
systemName: self.symbolName,
67+
withConfiguration: self.symbolConfig
68+
)!
69+
imageView.setSymbolImage(originalImage, contentTransition: .replace)
70+
}
71+
case .bounce:
72+
addSymbolEffect(.bounce.up)
73+
case .none:
74+
break
75+
}
76+
}
77+
}
78+
79+
// MARK: - State Toggle Support
80+
81+
/// Replaces the current symbol with a new one using the `.replace` content transition.
82+
func replaceSymbol(systemName: String) {
83+
guard #available(iOS 17.0, visionOS 1.0, *) else { return }
84+
let newImage = UIImage(systemName: systemName, withConfiguration: symbolConfig)!
85+
setSymbolImage(newImage, contentTransition: .replace)
86+
}
87+
88+
/// Adds an ongoing variable color effect (e.g., for active speaker).
89+
func startVariableColor() {
90+
guard #available(iOS 17.0, visionOS 1.0, *) else { return }
91+
addSymbolEffect(.variableColor.iterative)
92+
}
93+
94+
/// Removes the variable color effect.
95+
func stopVariableColor() {
96+
guard #available(iOS 17.0, visionOS 1.0, *) else { return }
97+
removeSymbolEffect(ofType: .variableColor)
98+
}
99+
}
100+
#endif

0 commit comments

Comments
 (0)