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 [![Build Status](https://github.com/Ramiz69/CameraButton/workflows/Camera%20Button%20CI/badge.svg?branch=master)](https://github.com/Ramiz69/CameraButton/actions) -[![Version](https://img.shields.io/cocoapods/v/RKCameraButton.svg?style=flat)](https://cocoapods.org/pods/RKCameraButton) -[![License](https://img.shields.io/cocoapods/l/RKCameraButton.svg?style=flat)](https://cocoapods.org/pods/RKCameraButton) -[![Platform](https://img.shields.io/cocoapods/p/RKCameraButton.svg?style=flat)](https://cocoapods.org/pods/RKCameraButton) -[![Swift](https://img.shields.io/badge/swift-5.9-orange.svg)](https://swift.org) +[![SPM](https://img.shields.io/badge/SPM-compatible-brightgreen.svg?style=flat)](https://swift.org/package-manager/) +[![License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat)](https://github.com/Ramiz69/CameraButton/blob/master/LICENSE) +[![Platform](https://img.shields.io/badge/platform-iOS%2015%2B-lightgrey.svg?style=flat)](https://developer.apple.com/ios/) +[![Swift](https://img.shields.io/badge/swift-6.0-orange.svg)](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() + } + } + +}