diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bb460e7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata diff --git a/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..b46b7c0 --- /dev/null +++ b/Package.swift @@ -0,0 +1,25 @@ +// swift-tools-version:5.5 + +import PackageDescription + +let package = Package( + name: "CameraButton", + platforms: [ + .iOS(.v10) + ], + products: [ + .library( + name: "CameraButton", + targets: ["CameraButton"]) + ], + targets: [ + .target( + name: "CameraButton", + dependencies: [], + path: "Sources" + ), + .testTarget( + name: "CameraButtonTests", + dependencies: ["CameraButton"]) + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..eb9bc72 --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +# CameraButton + +A simple camera button that can be used for photo and video capturing. It's a subclass of the native `UIButton` with attributes for customization. + +## Requirements + +iOS 10 or higher + +## Instalation + +### Swift Package Manager + +```Swift +dependencies: [ + .package(url: "https://github.com/erikdrobne/CameraButton") +] +``` + +## Usage + +### Import + +```Swift +import CameraButton +``` + +### Initialize + +```Swift +let button = CameraButton() +button.delegate = self + +view.addSubview(button) + +button.translatesAutoresizingMaskIntoConstraints = false +NSLayoutConstraint.activate([ + button.widthAnchor.constraint(equalToConstant: 72), + button.heightAnchor.constraint(equalToConstant: 72), + button.centerXAnchor.constraint(equalTo: view.centerXAnchor), + button.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -64) +]) +``` + +### Customize + +```Swift +button.borderColor = .red +button.fillColor = (.purple, .orange) +button.progressColor = .green + +// Set progress animation duration +button.progressDuration = 5 + +// Start progress animation +button.start() + +// Stop progress animation +button.stop() +``` + +### Delegate + +The `CameraButtonDelegate` requires you to implement the following methods: + +```Swift +func didTap(_ button: CameraButton) +func didFinishProgress() +``` diff --git a/Sources/CameraButton/CameraButton.swift b/Sources/CameraButton/CameraButton.swift new file mode 100644 index 0000000..1fa5fde --- /dev/null +++ b/Sources/CameraButton/CameraButton.swift @@ -0,0 +1,194 @@ +import Foundation +import UIKit + +public protocol CameraButtonDelegate: AnyObject { + func didTap(_ button: CameraButton) + func didFinishProgress() +} + +public class CameraButton: UIButton, CAAnimationDelegate { + + // MARK: - Private properties + + private let borderLayer = CAShapeLayer() + private let progressLayer = CAShapeLayer() + private let shapeLayer = CAShapeLayer() + + private (set) public var isRecording = false + + private struct Animation { + static let progress = (id: "progress", key: "strokeEnd", index: 0) + static let tap = (id: "tap", key: "transform.scale", index: 1) + } + + // MARK: - Public properties + + public weak var delegate: CameraButtonDelegate? + public var borderColor = UIColor.white + public var fillColor: (default: UIColor, record: UIColor) = (.white, .white) + public var progressColor = UIColor.red + public var progressDuration: TimeInterval = 5 + + // MARK: - Initialization + + public override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + public required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + } + + // MARK: - Lifecycle + + public override func layoutSubviews() { + super.layoutSubviews() + + layer.cornerRadius = min(bounds.width, bounds.height) / 2 + setupBorderLayer() + setupProgressLayer() + setupShapeLayer() + } + + // MARK: - Public methods + + public func start() { + guard !isRecording else { + return + } + + isRecording = true + borderLayer.opacity = 0 + progressLayer.opacity = 1 + shapeLayer.fillColor = fillColor.record.cgColor + + UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseInOut, animations: ({ + self.backgroundColor = self.fillColor.record.withAlphaComponent(0.6) + }), completion: { _ in + self.animateProgress(duration: self.progressDuration) + }) + } + + public func stop() { + guard isRecording else { + return + } + + isRecording = false + progressLayer.opacity = 0 + borderLayer.opacity = 1 + shapeLayer.fillColor = fillColor.default.cgColor + + UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseInOut, animations: ({ + self.backgroundColor = .clear + }), completion: { _ in + self.clearProgressAnimation() + }) + } + + // MARK: - Private methods + + private func setup() { + clipsToBounds = false + backgroundColor = .clear + addTarget(self, action: #selector(handleTap), for: .touchUpInside) + } + + private func setupBorderLayer() { + layer.addSublayer(borderLayer) + borderLayer.strokeColor = borderColor.cgColor + borderLayer.lineWidth = frame.width * 0.05 + borderLayer.fillColor = nil + borderLayer.position = CGPoint(x: layer.bounds.midX, y: layer.bounds.midY) + + let diameter = frame.width + let rect = CGRect(x: -diameter / 2, y: -diameter / 2, width: diameter, height: diameter) + borderLayer.path = UIBezierPath(ovalIn: rect).cgPath + } + + private func setupShapeLayer() { + layer.addSublayer(shapeLayer) + shapeLayer.fillColor = fillColor.default.cgColor + shapeLayer.position = CGPoint(x: layer.bounds.midX, y: layer.bounds.midY) + + let diameter = frame.width * 0.87 + let rect = CGRect(x: -diameter / 2, y: -diameter / 2, width: diameter, height: diameter) + shapeLayer.path = UIBezierPath(ovalIn: rect).cgPath + } + + private func setupProgressLayer() { + layer.addSublayer(progressLayer) + progressLayer.strokeColor = progressColor.cgColor + progressLayer.lineWidth = frame.width * 0.08 + progressLayer.opacity = 0 + progressLayer.strokeEnd = 0 + progressLayer.lineCap = .round + progressLayer.fillColor = nil + progressLayer.position = CGPoint(x: layer.bounds.midX, y: layer.bounds.midY) + + let diameter = frame.width + let rect = CGRect(x: -diameter / 2, y: -diameter / 2, width: diameter, height: diameter) + let path = UIBezierPath( + roundedRect: rect, + byRoundingCorners: .allCorners, + cornerRadii: CGSize(width: diameter, height: diameter) + ) + + progressLayer.path = path.cgPath + } + + private func animateProgress(duration t: TimeInterval) { + let animation = CABasicAnimation(keyPath: Animation.progress.key) + animation.delegate = self + animation.duration = t + animation.fromValue = 0 + animation.toValue = 1 + animation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.linear) + animation.setValue(Animation.progress.index, forKey: Animation.progress.id) + progressLayer.strokeEnd = 1.0 + progressLayer.add(animation, forKey: Animation.progress.key) + } + + private func clearProgressAnimation() { + progressLayer.removeAnimation(forKey: Animation.progress.key) + progressLayer.strokeEnd = 0 + progressLayer.opacity = 0 + progressLayer.layoutIfNeeded() + } + + private func animateTap(duration t: TimeInterval) { + let animation = CABasicAnimation(keyPath: Animation.tap.key) + animation.duration = t + animation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut) + animation.toValue = [0.9, 0.9] + animation.autoreverses = true + animation.setValue(Animation.tap.index, forKey: Animation.tap.id) + shapeLayer.add(animation, forKey: Animation.tap.key) + } + + @objc private func handleTap(_ sender: CameraButton) { + DispatchQueue.main.async { [weak self] in + UIImpactFeedbackGenerator(style: .light).impactOccurred() + self?.animateTap(duration: 0.15) + self?.delegate?.didTap(sender) + } + } + + // MARK: - CAAnimationDelegate + + public func animationDidStop(_ animation: CAAnimation, finished flag: Bool) { + guard + flag, + animation.value(forKey: Animation.progress.id) as? Int == Animation.progress.index + else { + return + } + + DispatchQueue.main.async { [weak self] in + self?.stop() + self?.delegate?.didFinishProgress() + } + } +} diff --git a/Tests/CameraButtonTests/CameraButtonTests.swift b/Tests/CameraButtonTests/CameraButtonTests.swift new file mode 100644 index 0000000..dc267ee --- /dev/null +++ b/Tests/CameraButtonTests/CameraButtonTests.swift @@ -0,0 +1,10 @@ +import XCTest +@testable import CameraButton + +final class CameraButtonTests: XCTestCase { + func testExample() throws { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct + // results. + } +}