diff --git a/.DS_Store b/.DS_Store
deleted file mode 100644
index cf17ef4..0000000
Binary files a/.DS_Store and /dev/null differ
diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml
index a055b9b..25cb9b5 100644
--- a/.github/workflows/swift.yml
+++ b/.github/workflows/swift.yml
@@ -1,20 +1,18 @@
name: "Camera Button CI"
-on:
+on:
push:
branches:
- master
+ pull_request:
jobs:
iOS:
- name: Test iOS
- runs-on: macOS-latest
- env:
- DEVELOPER_DIR: /Applications/Xcode_11.3.app/Contents/Developer
- strategy:
- matrix:
- destination: ["OS=13.1,name=iPhone 11 Pro"] #, "OS=12.4,name=iPhone XS", "OS=11.4,name=iPhone X", "OS=10.3.1,name=iPhone SE"]
+ name: Build iOS
+ runs-on: macos-15
steps:
- - uses: actions/checkout@v1
- - name: iOS - ${{ matrix.destination }}
- run: xcodebuild -project "CameraButton.xcodeproj" -destination "${{ matrix.destination }}"
+ - uses: actions/checkout@v4
+ - name: Build library
+ run: xcodebuild build -scheme CameraButton -destination 'generic/platform=iOS Simulator'
+ - name: Build example
+ run: xcodebuild build -project CameraButton.xcodeproj -scheme CameraButton-Example -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..d5106f1
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+.DS_Store
+xcuserdata/
+.build/
+.swiftpm/
+DerivedData/
diff --git a/CameraButton-Example/Base.lproj/Main.storyboard b/CameraButton-Example/Base.lproj/Main.storyboard
index fd19c51..4865e49 100644
--- a/CameraButton-Example/Base.lproj/Main.storyboard
+++ b/CameraButton-Example/Base.lproj/Main.storyboard
@@ -66,6 +66,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -155,6 +175,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/CameraButton-Example/GlassViewController.swift b/CameraButton-Example/GlassViewController.swift
new file mode 100644
index 0000000..2cfdbb9
--- /dev/null
+++ b/CameraButton-Example/GlassViewController.swift
@@ -0,0 +1,164 @@
+//
+// GlassViewController.swift
+// CameraButton-Example
+//
+// Copyright © 2026 Ramiz Kichibekov. All rights reserved.
+//
+
+import UIKit
+import SwiftUI
+import CameraButton
+
+final class GlassViewController: UIViewController {
+
+ // MARK: Properties
+
+ private let maxRecordingDuration: TimeInterval = 10
+
+ private lazy var cameraButton: CameraButton = {
+ let button = CameraButton()
+ button.style = .glass
+ button.delegate = self
+ button.translatesAutoresizingMaskIntoConstraints = false
+
+ return button
+ }()
+
+ private lazy var segmentedControl: UISegmentedControl = {
+ let control = UISegmentedControl(items: ["Camera", "Video"])
+ control.selectedSegmentIndex = .zero
+ control.addAction(UIAction { [unowned self] _ in
+ self.cameraButton.type = CameraType(withValue: control.selectedSegmentIndex)
+ }, for: .valueChanged)
+
+ return control
+ }()
+
+ // MARK: Life cycle
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+
+ navigationItem.titleView = segmentedControl
+ configureBackground()
+ configureLayout()
+ }
+
+ // MARK: Private methods
+
+ private func configureBackground() {
+ let gradient = CAGradientLayer()
+ gradient.colors = [UIColor.systemPink, .systemOrange, .systemTeal].map(\.cgColor)
+ gradient.startPoint = CGPoint(x: 0, y: 0)
+ gradient.endPoint = CGPoint(x: 1, y: 1)
+ let background = GradientView(gradient: gradient)
+ background.translatesAutoresizingMaskIntoConstraints = false
+ view.addSubview(background)
+ NSLayoutConstraint.activate([
+ background.topAnchor.constraint(equalTo: view.topAnchor),
+ background.bottomAnchor.constraint(equalTo: view.bottomAnchor),
+ background.leadingAnchor.constraint(equalTo: view.leadingAnchor),
+ background.trailingAnchor.constraint(equalTo: view.trailingAnchor)
+ ])
+ }
+
+ private func configureLayout() {
+ let uiKitLabel = makeCaption("UIKit · CameraButton")
+ let swiftUILabel = makeCaption("SwiftUI · CameraShutterButton")
+ let hosting = UIHostingController(rootView: SwiftUIDemo())
+ hosting.view.backgroundColor = .clear
+ hosting.view.translatesAutoresizingMaskIntoConstraints = false
+ addChild(hosting)
+
+ let stack = UIStackView(arrangedSubviews: [uiKitLabel, cameraButton, swiftUILabel, hosting.view])
+ stack.axis = .vertical
+ stack.alignment = .center
+ stack.spacing = 16
+ stack.setCustomSpacing(56, after: cameraButton)
+ stack.translatesAutoresizingMaskIntoConstraints = false
+ view.addSubview(stack)
+ hosting.didMove(toParent: self)
+
+ NSLayoutConstraint.activate([
+ cameraButton.widthAnchor.constraint(equalToConstant: 76),
+ cameraButton.heightAnchor.constraint(equalToConstant: 76),
+ stack.centerXAnchor.constraint(equalTo: view.centerXAnchor),
+ stack.centerYAnchor.constraint(equalTo: view.centerYAnchor)
+ ])
+ }
+
+ private func makeCaption(_ text: String) -> UILabel {
+ let label = UILabel()
+ label.text = text
+ label.font = .preferredFont(forTextStyle: .footnote)
+ label.textColor = .white
+
+ return label
+ }
+
+}
+
+// MARK: - CameraButtonDelegate
+
+extension GlassViewController: CameraButtonDelegate {
+
+ func didTapButton(_ button: CameraButton) {
+ guard button.type == .video, button.isSelected else { return }
+
+ button.animateRecordingProgress(duration: maxRecordingDuration)
+ }
+
+}
+
+// MARK: - SwiftUI demo
+
+private struct SwiftUIDemo: View {
+
+ @State private var isRecording = false
+ @State private var takenPhotos = 0
+
+ var body: some View {
+ HStack(spacing: 40) {
+ VStack(spacing: 8) {
+ CameraShutterButton(type: .photo, style: .glass) {
+ takenPhotos += 1
+ }
+ .frame(width: 64, height: 64)
+ Text("Photos: \(takenPhotos)")
+ }
+ VStack(spacing: 8) {
+ CameraShutterButton(type: .video, style: .glass, isRecording: $isRecording)
+ .frame(width: 64, height: 64)
+ Text(isRecording ? "Recording" : "Idle")
+ }
+ }
+ .font(.footnote)
+ .foregroundStyle(.white)
+ }
+
+}
+
+// MARK: - GradientView
+
+private final class GradientView: UIView {
+
+ private let gradient: CAGradientLayer
+
+ init(gradient: CAGradientLayer) {
+ self.gradient = gradient
+ super.init(frame: .zero)
+
+ layer.addSublayer(gradient)
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func layoutSubviews() {
+ super.layoutSubviews()
+
+ gradient.frame = bounds
+ }
+
+}
diff --git a/CameraButton.xcodeproj/project.pbxproj b/CameraButton.xcodeproj/project.pbxproj
index b74449c..82f7f46 100644
--- a/CameraButton.xcodeproj/project.pbxproj
+++ b/CameraButton.xcodeproj/project.pbxproj
@@ -11,6 +11,8 @@
4B8D4C8B2D2BFD0200751CC3 /* CameraType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B8D4C862D2BFD0200751CC3 /* CameraType.swift */; };
4B8D4C8C2D2BFD0200751CC3 /* CameraButton.h in Headers */ = {isa = PBXBuildFile; fileRef = 4B8D4C842D2BFD0200751CC3 /* CameraButton.h */; };
4BA40D722D2C005D007466AD /* CameraButtonDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BA40D712D2C0057007466AD /* CameraButtonDelegate.swift */; };
+ 4BF1A0032E9A000000C0FFEE /* CameraShutterButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BF1A0022E9A000000C0FFEE /* CameraShutterButton.swift */; };
+ 4BF1A0052E9A000000C0FFEE /* GlassViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BF1A0042E9A000000C0FFEE /* GlassViewController.swift */; };
4BC6F2EF257F9D3000691CE9 /* CustomViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BC6F2EE257F9D3000691CE9 /* CustomViewController.swift */; };
4BE099C5233D339B00AE7670 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BE099C4233D339B00AE7670 /* AppDelegate.swift */; };
4BE099C7233D339B00AE7670 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BE099C6233D339B00AE7670 /* SceneDelegate.swift */; };
@@ -52,6 +54,8 @@
4B8D4C862D2BFD0200751CC3 /* CameraType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraType.swift; sourceTree = ""; };
4B8D4C872D2BFD0200751CC3 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
4BA40D712D2C0057007466AD /* CameraButtonDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraButtonDelegate.swift; sourceTree = ""; };
+ 4BF1A0022E9A000000C0FFEE /* CameraShutterButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraShutterButton.swift; sourceTree = ""; };
+ 4BF1A0042E9A000000C0FFEE /* GlassViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlassViewController.swift; sourceTree = ""; };
4BC6F2EE257F9D3000691CE9 /* CustomViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomViewController.swift; sourceTree = ""; };
4BE099B1233D331200AE7670 /* CameraButton.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = CameraButton.framework; sourceTree = BUILT_PRODUCTS_DIR; };
4BE099C2233D339B00AE7670 /* CameraButton-Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "CameraButton-Example.app"; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -87,6 +91,7 @@
isa = PBXGroup;
children = (
4BA40D702D2C0050007466AD /* Protocols */,
+ 4BF1A0012E9A000000C0FFEE /* SwiftUI */,
4B8D4C842D2BFD0200751CC3 /* CameraButton.h */,
4B8D4C852D2BFD0200751CC3 /* CameraButton.swift */,
4B8D4C862D2BFD0200751CC3 /* CameraType.swift */,
@@ -103,6 +108,14 @@
path = Protocols;
sourceTree = "";
};
+ 4BF1A0012E9A000000C0FFEE /* SwiftUI */ = {
+ isa = PBXGroup;
+ children = (
+ 4BF1A0022E9A000000C0FFEE /* CameraShutterButton.swift */,
+ );
+ path = SwiftUI;
+ sourceTree = "";
+ };
4BE099A7233D331200AE7670 = {
isa = PBXGroup;
children = (
@@ -129,6 +142,7 @@
4BE099C6233D339B00AE7670 /* SceneDelegate.swift */,
4BE099C8233D339B00AE7670 /* BasicViewController.swift */,
4BC6F2EE257F9D3000691CE9 /* CustomViewController.swift */,
+ 4BF1A0042E9A000000C0FFEE /* GlassViewController.swift */,
4BE099CA233D339B00AE7670 /* Main.storyboard */,
4BE099CD233D339D00AE7670 /* Assets.xcassets */,
4BE099CF233D339D00AE7670 /* LaunchScreen.storyboard */,
@@ -262,6 +276,7 @@
files = (
4B8D4C8A2D2BFD0200751CC3 /* CameraButton.swift in Sources */,
4BA40D722D2C005D007466AD /* CameraButtonDelegate.swift in Sources */,
+ 4BF1A0032E9A000000C0FFEE /* CameraShutterButton.swift in Sources */,
4B8D4C8B2D2BFD0200751CC3 /* CameraType.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -274,6 +289,7 @@
4BE099C5233D339B00AE7670 /* AppDelegate.swift in Sources */,
4BE099C7233D339B00AE7670 /* SceneDelegate.swift in Sources */,
4BC6F2EF257F9D3000691CE9 /* CustomViewController.swift in Sources */,
+ 4BF1A0052E9A000000C0FFEE /* GlassViewController.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
diff --git a/Package.swift b/Package.swift
index 0359581..7f88c5c 100644
--- a/Package.swift
+++ b/Package.swift
@@ -5,7 +5,7 @@ import PackageDescription
let package = Package(
name: "CameraButton",
platforms: [
- .iOS(.v14)
+ .iOS(.v15)
],
products: [
.library(
diff --git a/README.md b/README.md
index c91c92a..d7e1cd0 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,10 @@
# CameraButton
[](https://github.com/Ramiz69/CameraButton/actions)
-[](https://cocoapods.org/pods/RKCameraButton)
-[](https://cocoapods.org/pods/RKCameraButton)
-[](https://cocoapods.org/pods/RKCameraButton)
-[](https://swift.org)
+[](https://swift.org/package-manager/)
+[](https://github.com/Ramiz69/CameraButton/blob/master/LICENSE)
+[](https://developer.apple.com/ios/)
+[](https://swift.org)
- [Installation](#installation)
- [Author](#author)
@@ -13,8 +13,8 @@
## Requirements
- iOS 15.0+
-- Xcode 16.0+
-- Swift 5+
+- Xcode 26.0+ (Liquid Glass style requires iOS 26; earlier systems fall back to the classic style)
+- Swift 6.0+
## Preview
@@ -25,11 +25,11 @@
The [Swift Package Manager](https://swift.org/package-manager/) is a tool for automating the distribution of Swift code and is integrated into the `swift` compiler.
-Once you have your Swift package set up, adding Slider as a dependency is as easy as adding it to the `dependencies` value of your `Package.swift` or the Package list in Xcode.
+Once you have your Swift package set up, adding CameraButton as a dependency is as easy as adding it to the `dependencies` value of your `Package.swift` or the Package list in Xcode.
```swift
dependencies: [
- .package(url: "https://github.com/Ramiz69/CameraButton.git", .upToNextMajor(from: "0.0.4"))
+ .package(url: "https://github.com/Ramiz69/CameraButton.git", .upToNextMajor(from: "1.0.0"))
]
```
@@ -41,25 +41,59 @@ Normally you'll want to depend on the `CameraButton` target:
### CocoaPods
-[CocoaPods](https://cocoapods.org) is a dependency manager for Cocoa projects. For usage and installation instructions, visit their website. To integrate CameraButton into your Xcode project using CocoaPods, specify it in your `Podfile`:
-
-```ruby
-pod 'RKCameraButton'
-```
+CocoaPods is no longer supported starting with 1.0.0. The last published pod is `RKCameraButton 0.0.4`. Please migrate to Swift Package Manager.
### Manually
-copy `CameraButton.swift` to your project
+Copy the contents of the `Sources` folder into your project.
### Usage
-#### code
-- init CameraButton with frame or use Auto Layout
-- add a button to your view
+#### UIKit
+
+```swift
+let button = CameraButton()
+button.type = .video // .photo or .video
+button.style = .glass // Liquid Glass on iOS 26+, classic ring otherwise
+button.delegate = self
+view.addSubview(button) // 60×60 intrinsic size, or constrain it
+```
+
+In storyboards, add a button and set its class to `CameraButton` in the Identity Inspector.
+
+#### SwiftUI
+
+```swift
+@State private var isRecording = false
+
+CameraShutterButton(type: .video, style: .glass, isRecording: $isRecording) {
+ isRecording ? recorder.start() : recorder.stop()
+}
+.frame(width: 72, height: 72)
+```
+
+`CameraShutterButton` is a thin wrapper over the same `CameraButton`, so both share one implementation.
+
+#### Recording progress
+
+```swift
+button.animateRecordingProgress(duration: 15) // fills the ring over the max clip length
+button.recordingProgress = 0.4 // or drive it manually
+```
+
+The ring resets when recording stops.
+
+#### Customization
+
+| Property | Description |
+| --- | --- |
+| `style` | `.classic` or `.glass` |
+| `glassTintColor` | Tint of the glass disc |
+| `photoColor`, `videoColor` | Fill of the inner shape |
+| `ringColor`, `ringLineWidth`, `ringOffset` | Classic ring appearance |
+| `progressColor` | Recording ring color |
+| `isHapticFeedbackEnabled` | Haptics on capture and record toggle |
-#### storyboard
-- add a button to your view
-- in the "Identity Inspector" in xCode, change the class to "CameraButton"
-- add constraint for the placement of the button
+The button supports VoiceOver labels, Dynamic colors and Reduce Motion out of the box.
## Author
diff --git a/RKCameraButton.podspec b/RKCameraButton.podspec
deleted file mode 100644
index 223cc41..0000000
--- a/RKCameraButton.podspec
+++ /dev/null
@@ -1,26 +0,0 @@
-Pod::Spec.new do |spec|
-
- spec.name = 'RKCameraButton'
- spec.version = '0.0.4'
- spec.summary = 'A CocoaPods library written in Swift'
-
- spec.description = <<-DESC
-This CocoaPods library helps you create application with the best button.
- DESC
-
- spec.homepage = 'https://github.com/Ramiz69/CameraButton'
-
- spec.license = 'MIT'
-
- spec.author = { 'Ramiz Kichibekov' => 'ramiz161@icloud.com' }
- spec.social_media_url = 'https://t.me/Ramiz69'
-
- spec.ios.deployment_target = "15.0"
-
- spec.source = { :git => 'https://github.com/Ramiz69/CameraButton.git', :tag => spec.version }
-
- spec.swift_version = ['6.0']
-
- spec.source_files = 'Sources/*.swift', 'Sources/**/*.swift'
-
-end
diff --git a/Sources/.DS_Store b/Sources/.DS_Store
deleted file mode 100644
index cb71784..0000000
Binary files a/Sources/.DS_Store and /dev/null differ
diff --git a/Sources/CameraButton.swift b/Sources/CameraButton.swift
index 0626180..cb34335 100644
--- a/Sources/CameraButton.swift
+++ b/Sources/CameraButton.swift
@@ -26,17 +26,45 @@ import UIKit
open class CameraButton: UIButton {
+ /// Visual style of the button.
+ public enum Style: Sendable {
+ /// Stroked ring with a filled inner shape.
+ case classic
+ /// Liquid Glass disc on iOS 26 and later. Falls back to `classic` on earlier systems.
+ case glass
+ }
+
+ // MARK: Style
+
+ public var style: Style = .classic {
+ didSet {
+ guard oldValue != style else { return }
+
+ updateStyle()
+ }
+ }
+
+ /// Tint of the Liquid Glass disc. Applies to the `glass` style only.
+ public var glassTintColor: UIColor? {
+ didSet {
+ updateStyle()
+ }
+ }
+
+ /// Plays haptic feedback on capture and on start/stop of recording.
+ public var isHapticFeedbackEnabled = true
+
// MARK: Colors
public var photoColor: UIColor = .clear {
didSet {
- setNeedsDisplay()
+ updateFillColor(configureColor(), animated: false)
}
}
public var videoColor: UIColor = .clear {
didSet {
- setNeedsDisplay()
+ updateFillColor(configureColor(), animated: false)
}
}
@@ -44,13 +72,13 @@ open class CameraButton: UIButton {
public var ringOffset: CGFloat = 3 {
didSet {
- setNeedsDisplay()
+ updateRing()
}
}
public var ringLineWidth: CGFloat = 4 {
didSet {
- setNeedsDisplay()
+ updateRing()
}
}
@@ -60,11 +88,30 @@ open class CameraButton: UIButton {
}
}
+ // MARK: Recording progress
+
+ public var progressColor: UIColor = .systemRed {
+ didSet {
+ updateProgressColor()
+ }
+ }
+
+ /// Progress of the recording ring, from 0 to 1. Resets when recording stops.
+ public var recordingProgress: CGFloat {
+ get { progressLayer.strokeEnd }
+ set {
+ progressLayer.removeAnimation(forKey: "strokeEnd")
+ CATransaction.performWithoutAnimation {
+ progressLayer.strokeEnd = min(max(newValue, 0), 1)
+ }
+ }
+ }
+
// MARK: Inner large circle
public var innerLargeCircleOffset: CGFloat = 8 {
didSet {
- configureLayer()
+ updateInnerPath(animated: false)
}
}
@@ -72,7 +119,7 @@ open class CameraButton: UIButton {
public var innerSmallCircleOffset: CGFloat = 13 {
didSet {
- configureLayer()
+ updateInnerPath(animated: false)
}
}
@@ -80,13 +127,13 @@ open class CameraButton: UIButton {
public var innerSquare: CGFloat = 17 {
didSet {
- configureLayer()
+ updateInnerPath(animated: false)
}
}
public var squareCornerRadius: CGFloat = 8 {
didSet {
- configureLayer()
+ updateInnerPath(animated: false)
}
}
@@ -94,23 +141,28 @@ open class CameraButton: UIButton {
override open var isSelected: Bool {
didSet {
- let morph = CABasicAnimation(keyPath: "path")
- morph.duration = animateDuration
- morph.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
- morph.toValue = currentInnerPath().cgPath
- morph.fillMode = .forwards
- morph.isRemovedOnCompletion = false
- pathLayer.add(morph, forKey: "")
+ guard oldValue != isSelected else { return }
+
+ updateInnerPath(animated: true)
+ updateAccessibility()
+ if !isSelected {
+ recordingProgress = 0
+ }
}
}
+ override open var intrinsicContentSize: CGSize {
+ CGSize(width: 60, height: 60)
+ }
+
// MARK: Public properties
public var type: CameraType = .photo {
didSet {
pathLayer.removeAllAnimations()
- pathLayer.fillColor = configureColor().cgColor
isSelected = false
+ updateFillColor(configureColor(), animated: false)
+ updateAccessibility()
}
}
public weak var delegate: CameraButtonDelegate?
@@ -118,8 +170,29 @@ open class CameraButton: UIButton {
// MARK: Private properties
private let pathLayer = CAShapeLayer()
+ private let progressLayer = CAShapeLayer()
+ private var glassView: UIView?
+ private let impactGenerator = UIImpactFeedbackGenerator(style: .medium)
private let animateDuration = CATransaction.animationDuration()
- private let colorChangeAnimator = CABasicAnimation(keyPath: "fillColor")
+ private let shutterDuration: CFTimeInterval = 0.3
+ private var lastLayoutSize: CGSize = .zero
+ private var customAccessibilityLabel: String?
+
+ // MARK: Accessibility
+
+ override open var accessibilityLabel: String? {
+ get {
+ if let customAccessibilityLabel { return customAccessibilityLabel }
+
+ switch type {
+ case .photo: return "Take Photo"
+ case .video: return isSelected ? "Stop Recording" : "Record Video"
+ }
+ }
+ set {
+ customAccessibilityLabel = newValue
+ }
+ }
// MARK: Life cycle
@@ -141,92 +214,300 @@ open class CameraButton: UIButton {
configureButton()
}
- override open func awakeFromNib() {
- super.awakeFromNib()
+ override open func layoutSubviews() {
+ super.layoutSubviews()
- Task {
- await MainActor.run {
- addConstraint(NSLayoutConstraint(item: self,
- attribute: .width,
- relatedBy: .equal,
- toItem: nil,
- attribute: .width,
- multiplier: 1,
- constant: frame.width))
- addConstraint(NSLayoutConstraint(item: self,
- attribute: .height,
- relatedBy: .equal,
- toItem: nil,
- attribute: .width,
- multiplier: 1,
- constant: frame.height))
- }
+ guard bounds.size != lastLayoutSize else { return }
+
+ lastLayoutSize = bounds.size
+ pathLayer.removeAllAnimations()
+ CATransaction.performWithoutAnimation {
+ pathLayer.frame = bounds
+ progressLayer.frame = bounds
+ progressLayer.path = progressPath().cgPath
}
+ glassView?.frame = bounds
+ glassView?.layer.cornerRadius = min(bounds.width, bounds.height) / 2
+ updateInnerPath(animated: false)
+ setNeedsDisplay()
}
override open func draw(_ rect: CGRect) {
super.draw(rect)
- let ringRect = CGRect(x: ringOffset,
- y: ringOffset,
- width: frame.width - ringOffset * 2,
- height: frame.height - ringOffset * 2)
+ guard !isGlassActive else { return }
+
+ let ringRect = bounds.insetBy(dx: ringOffset, dy: ringOffset)
let outerRing = UIBezierPath(ovalIn: ringRect)
outerRing.lineWidth = ringLineWidth
ringColor.setStroke()
outerRing.stroke()
- pathLayer.fillColor = configureColor().cgColor
+ }
+
+ override open func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
+ super.traitCollectionDidChange(previousTraitCollection)
+
+ guard traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) else { return }
+
+ updateFillColor(configureColor(), animated: false)
+ updateProgressColor()
+ setNeedsDisplay()
+ }
+
+ // MARK: Public methods
+
+ /// Animates the recording ring from its current value to full over `duration` seconds.
+ public func animateRecordingProgress(duration: TimeInterval) {
+ let fill = CABasicAnimation(keyPath: "strokeEnd")
+ fill.fromValue = progressLayer.presentation()?.strokeEnd ?? progressLayer.strokeEnd
+ fill.toValue = 1
+ fill.duration = duration
+ fill.timingFunction = CAMediaTimingFunction(name: .linear)
+ progressLayer.add(fill, forKey: "strokeEnd")
+ CATransaction.performWithoutAnimation {
+ progressLayer.strokeEnd = 1
+ }
}
// MARK: Private methods
private func configureLayer() {
pathLayer.removeAllAnimations()
- pathLayer.removeFromSuperlayer()
- pathLayer.path = currentInnerPath().cgPath
pathLayer.strokeColor = nil
- layer.addSublayer(pathLayer)
+ if pathLayer.superlayer == nil {
+ layer.addSublayer(pathLayer)
+ }
+ progressLayer.fillColor = nil
+ progressLayer.lineCap = .round
+ progressLayer.strokeEnd = 0
+ if progressLayer.superlayer == nil {
+ layer.addSublayer(progressLayer)
+ }
+ updateProgressColor()
+ updateFillColor(configureColor(), animated: false)
+ updateInnerPath(animated: false)
+ }
+
+ private var isGlassActive: Bool {
+ glassView != nil
+ }
+
+ private func updateStyle() {
+ glassView?.removeFromSuperview()
+ glassView = makeGlassView()
+ if let glassView {
+ glassView.frame = bounds
+ glassView.layer.cornerRadius = min(bounds.width, bounds.height) / 2
+ insertSubview(glassView, at: 0)
+ layer.insertSublayer(pathLayer, above: glassView.layer)
+ layer.insertSublayer(progressLayer, above: pathLayer)
+ }
+ updateFillColor(configureColor(), animated: false)
+ updateRing()
+ }
+
+ private func makeGlassView() -> UIView? {
+ guard style == .glass else { return nil }
+
+ #if compiler(>=6.2)
+ if #available(iOS 26.0, *) {
+ let effect = UIGlassEffect()
+ effect.tintColor = glassTintColor
+ let effectView = UIVisualEffectView(effect: effect)
+ effectView.isUserInteractionEnabled = false
+ effectView.clipsToBounds = true
+
+ return effectView
+ }
+ #endif
+
+ return nil
+ }
+
+ private func updateRing() {
+ updateProgressColor()
+ CATransaction.performWithoutAnimation {
+ progressLayer.path = progressPath().cgPath
+ }
+ setNeedsDisplay()
+ }
+
+ private func updateProgressColor() {
+ progressLayer.strokeColor = progressColor.resolvedColor(with: traitCollection).cgColor
+ progressLayer.lineWidth = ringLineWidth
+ }
+
+ private func progressPath() -> UIBezierPath {
+ let inset = isGlassActive ? ringLineWidth / 2 : ringOffset
+ let radius = min(bounds.width, bounds.height) / 2 - inset
+
+ return UIBezierPath(arcCenter: CGPoint(x: bounds.midX, y: bounds.midY),
+ radius: max(radius, 0),
+ startAngle: -.pi / 2,
+ endAngle: .pi * 3 / 2,
+ clockwise: true)
+ }
+
+ private func setPressed(_ pressed: Bool, pulse: Bool = false) {
+ guard isGlassActive else { return }
+
+ let pressedTransform = CGAffineTransform(scaleX: 0.92, y: 0.92)
+ let transform = pressed ? pressedTransform : .identity
+ guard !UIAccessibility.isReduceMotionEnabled else {
+ self.transform = transform
+ return
+ }
+
+ let options: UIView.AnimationOptions = [.allowUserInteraction, .beginFromCurrentState]
+ let release = {
+ UIView.animate(withDuration: 0.35,
+ delay: 0,
+ usingSpringWithDamping: 0.6,
+ initialSpringVelocity: 0,
+ options: options) {
+ self.transform = transform
+ }
+ }
+ guard pulse else {
+ release()
+ return
+ }
+
+ UIView.animate(withDuration: shutterDuration * 0.4, delay: 0, options: options) {
+ self.transform = pressedTransform
+ } completion: { _ in
+ release()
+ }
+ }
+
+ private func playHaptic() {
+ guard isHapticFeedbackEnabled else { return }
+
+ impactGenerator.impactOccurred()
}
private func configureButton() {
configureLayer()
setTitle("", for: .normal)
tintColor = .clear
- let touchUpInsideAction = UIAction { [unowned self] _ in
- self.colorChangeAnimator.duration = animateDuration
- self.colorChangeAnimator.toValue = configureColor().cgColor
-
- self.colorChangeAnimator.fillMode = .forwards
- self.colorChangeAnimator.isRemovedOnCompletion = false
-
- self.colorChangeAnimator.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
- self.pathLayer.add(colorChangeAnimator, forKey: "darkColor")
- self.isSelected = !self.isSelected
- self.delegate?.didTapButton(self)
- }
- addAction(touchUpInsideAction, for: .touchUpInside)
- let touchDownAction = UIAction { [unowned self] _ in
- self.colorChangeAnimator.duration = animateDuration
- self.colorChangeAnimator.toValue = type == .photo && photoColor == .clear ? UIColor.white.cgColor : configureColor().cgColor
-
- self.colorChangeAnimator.fillMode = .forwards
- self.colorChangeAnimator.isRemovedOnCompletion = false
-
- self.colorChangeAnimator.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
- self.pathLayer.add(colorChangeAnimator, forKey: "darkColor")
- if self.type == .photo {
- self.isSelected = !isSelected
- }
- self.delegate?.didTapDownButton(self)
+ isAccessibilityElement = true
+ updateAccessibility()
+
+ addAction(UIAction { [unowned self] _ in
+ self.handleTouchDown()
+ }, for: .touchDown)
+ addAction(UIAction { [unowned self] _ in
+ self.handleTouchUpInside()
+ }, for: .touchUpInside)
+ addAction(UIAction { [unowned self] _ in
+ self.handleTouchCancel()
+ }, for: [.touchUpOutside, .touchCancel])
+ }
+
+ private func handleTouchDown() {
+ let pressedColor = type == .photo && photoColor == .clear ? UIColor.white : configureColor()
+ updateFillColor(pressedColor, animated: true)
+ if type == .photo {
+ isSelected = true
}
- addAction(touchDownAction, for: .touchDown)
- let touchCancelAction = UIAction { [unowned self] _ in
- self.isSelected = !self.isSelected
- self.colorChangeAnimator.toValue = self.configureColor().cgColor
- self.pathLayer.add(colorChangeAnimator, forKey: "darkColor")
- self.delegate?.didTapCancelButton(self)
+ setPressed(true)
+ if isHapticFeedbackEnabled {
+ impactGenerator.prepare()
}
- addAction(touchCancelAction, for: .touchCancel)
+ delegate?.didTapDownButton(self)
+ }
+
+ private func handleTouchUpInside() {
+ switch type {
+ case .photo:
+ playShutterAnimation()
+ case .video:
+ updateFillColor(configureColor(), animated: true)
+ isSelected.toggle()
+ setPressed(false)
+ }
+ playHaptic()
+ delegate?.didTapButton(self)
+ }
+
+ private func handleTouchCancel() {
+ updateFillColor(configureColor(), animated: true)
+ if type == .photo {
+ isSelected = false
+ }
+ setPressed(false)
+ delegate?.didTapCancelButton(self)
+ }
+
+ /// Plays a full press-and-release of the inner circle so that a quick tap is visible
+ /// even when touch down and touch up arrive almost at the same time.
+ private func playShutterAnimation() {
+ let currentPath = pathLayer.presentation()?.path ?? pathLayer.path
+ let currentColor = pathLayer.presentation()?.fillColor ?? pathLayer.fillColor
+ let pressedColor = (photoColor == .clear ? UIColor.white : configureColor()).resolvedColor(with: traitCollection).cgColor
+ let normalColor = configureColor().resolvedColor(with: traitCollection).cgColor
+
+ isSelected = false
+ CATransaction.performWithoutAnimation {
+ pathLayer.fillColor = normalColor
+ }
+ setPressed(false, pulse: true)
+
+ guard !UIAccessibility.isReduceMotionEnabled else {
+ pathLayer.removeAllAnimations()
+ return
+ }
+
+ let timing = CAMediaTimingFunction(name: .easeInEaseOut)
+ let pathPulse = CAKeyframeAnimation(keyPath: "path")
+ pathPulse.values = [currentPath as Any, innerSquarePath().cgPath, innerCirclePath().cgPath]
+ pathPulse.keyTimes = [0, 0.4, 1]
+ pathPulse.timingFunctions = [timing, timing]
+ pathPulse.duration = shutterDuration
+ pathLayer.add(pathPulse, forKey: "path")
+
+ let colorPulse = CAKeyframeAnimation(keyPath: "fillColor")
+ colorPulse.values = [currentColor as Any, pressedColor, normalColor]
+ colorPulse.keyTimes = [0, 0.4, 1]
+ colorPulse.timingFunctions = [timing, timing]
+ colorPulse.duration = shutterDuration
+ pathLayer.add(colorPulse, forKey: "fillColor")
+ }
+
+ private func updateInnerPath(animated: Bool) {
+ let newPath = currentInnerPath().cgPath
+ if animated && !UIAccessibility.isReduceMotionEnabled {
+ let morph = CABasicAnimation(keyPath: "path")
+ morph.fromValue = pathLayer.presentation()?.path ?? pathLayer.path
+ morph.toValue = newPath
+ morph.duration = animateDuration
+ morph.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
+ pathLayer.add(morph, forKey: "path")
+ }
+ CATransaction.performWithoutAnimation {
+ pathLayer.path = newPath
+ }
+ }
+
+ private func updateFillColor(_ color: UIColor, animated: Bool) {
+ let newColor = color.resolvedColor(with: traitCollection).cgColor
+ if animated {
+ let colorChange = CABasicAnimation(keyPath: "fillColor")
+ colorChange.fromValue = pathLayer.presentation()?.fillColor ?? pathLayer.fillColor
+ colorChange.toValue = newColor
+ colorChange.duration = animateDuration
+ colorChange.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
+ pathLayer.add(colorChange, forKey: "fillColor")
+ } else {
+ pathLayer.removeAnimation(forKey: "fillColor")
+ }
+ CATransaction.performWithoutAnimation {
+ pathLayer.fillColor = newColor
+ }
+ }
+
+ private func updateAccessibility() {
+ accessibilityTraits = type == .video && isSelected ? [.button, .selected] : .button
}
private func currentInnerPath() -> UIBezierPath {
@@ -234,10 +515,7 @@ open class CameraButton: UIButton {
}
private func innerCirclePath() -> UIBezierPath {
- let rect = CGRect(x: innerLargeCircleOffset,
- y: innerLargeCircleOffset,
- width: frame.width - innerLargeCircleOffset * 2,
- height: frame.height - innerLargeCircleOffset * 2)
+ let rect = bounds.insetBy(dx: innerLargeCircleOffset, dy: innerLargeCircleOffset)
return UIBezierPath(roundedRect: rect, cornerRadius: rect.width / 2)
}
@@ -245,17 +523,11 @@ open class CameraButton: UIButton {
private func innerSquarePath() -> UIBezierPath {
switch type {
case .photo:
- let rect = CGRect(x: innerSmallCircleOffset,
- y: innerSmallCircleOffset,
- width: frame.width - innerSmallCircleOffset * 2,
- height: frame.height - innerSmallCircleOffset * 2)
+ let rect = bounds.insetBy(dx: innerSmallCircleOffset, dy: innerSmallCircleOffset)
return UIBezierPath(roundedRect: rect, cornerRadius: rect.width / 2)
case .video:
- let rect = CGRect(x: innerCirclePath().currentPoint.x / 2,
- y: innerCirclePath().currentPoint.x / 2,
- width: frame.width / 2,
- height: frame.height / 2)
+ let rect = bounds.insetBy(dx: bounds.width / 4, dy: bounds.height / 4)
return UIBezierPath(roundedRect: rect, cornerRadius: squareCornerRadius)
}
@@ -263,9 +535,21 @@ open class CameraButton: UIButton {
private func configureColor() -> UIColor {
switch type {
+ case .photo where photoColor == .clear && isGlassActive: .white
case .photo: photoColor == .clear ? type.color : photoColor
case .video: videoColor == .clear ? type.color : videoColor
}
}
}
+
+private extension CATransaction {
+
+ static func performWithoutAnimation(_ changes: () -> Void) {
+ begin()
+ setDisableActions(true)
+ changes()
+ commit()
+ }
+
+}
diff --git a/Sources/SwiftUI/CameraShutterButton.swift b/Sources/SwiftUI/CameraShutterButton.swift
new file mode 100644
index 0000000..186756b
--- /dev/null
+++ b/Sources/SwiftUI/CameraShutterButton.swift
@@ -0,0 +1,89 @@
+//
+// CameraShutterButton.swift
+// CameraButton
+//
+// Copyright © 2026 Ramiz Kichibekov. All rights reserved.
+//
+
+import SwiftUI
+
+/// SwiftUI wrapper around `CameraButton`.
+///
+/// ```swift
+/// CameraShutterButton(type: .video, style: .glass, isRecording: $isRecording) {
+/// isRecording ? recorder.start() : recorder.stop()
+/// }
+/// .frame(width: 72, height: 72)
+/// ```
+public struct CameraShutterButton: UIViewRepresentable {
+
+ // MARK: Properties
+
+ private let type: CameraType
+ private let style: CameraButton.Style
+ private let recordingProgress: CGFloat
+ private let action: () -> Void
+ @Binding private var isRecording: Bool
+
+ // MARK: Life cycle
+
+ public init(type: CameraType = .photo,
+ style: CameraButton.Style = .classic,
+ isRecording: Binding = .constant(false),
+ recordingProgress: CGFloat = 0,
+ action: @escaping () -> Void = {}) {
+ self.type = type
+ self.style = style
+ self._isRecording = isRecording
+ self.recordingProgress = recordingProgress
+ self.action = action
+ }
+
+ // MARK: UIViewRepresentable
+
+ public func makeUIView(context: Context) -> CameraButton {
+ let button = CameraButton()
+ button.delegate = context.coordinator
+ button.setContentHuggingPriority(.required, for: .horizontal)
+ button.setContentHuggingPriority(.required, for: .vertical)
+
+ return button
+ }
+
+ public func updateUIView(_ button: CameraButton, context: Context) {
+ context.coordinator.parent = self
+ button.style = style
+ if button.type != type {
+ button.type = type
+ }
+ if type == .video, button.isSelected != isRecording {
+ button.isSelected = isRecording
+ }
+ if recordingProgress != button.recordingProgress {
+ button.recordingProgress = recordingProgress
+ }
+ }
+
+ public func makeCoordinator() -> Coordinator {
+ Coordinator(parent: self)
+ }
+
+ // MARK: Coordinator
+
+ public final class Coordinator: CameraButtonDelegate {
+
+ fileprivate var parent: CameraShutterButton
+
+ fileprivate init(parent: CameraShutterButton) {
+ self.parent = parent
+ }
+
+ public func didTapButton(_ button: CameraButton) {
+ if button.type == .video {
+ parent.isRecording = button.isSelected
+ }
+ parent.action()
+ }
+ }
+
+}