diff --git a/CameraButton-Example/AppDelegate.swift b/CameraButton-Example/AppDelegate.swift deleted file mode 100644 index a75038a..0000000 --- a/CameraButton-Example/AppDelegate.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// AppDelegate.swift -// CameraButton-Example -// -// Created by Рамиз Кичибеков on 26/09/2019. -// Copyright © 2019 Ramiz Kichibekov. All rights reserved. -// - -import UIKit - -@main -class AppDelegate: UIResponder, UIApplicationDelegate { - - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - true - } - - // MARK: UISceneSession Lifecycle - - func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { - UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) - } - - func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { - } - -} diff --git a/CameraButton-Example/Base.lproj/LaunchScreen.storyboard b/CameraButton-Example/Base.lproj/LaunchScreen.storyboard deleted file mode 100644 index 865e932..0000000 --- a/CameraButton-Example/Base.lproj/LaunchScreen.storyboard +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/CameraButton-Example/Base.lproj/Main.storyboard b/CameraButton-Example/Base.lproj/Main.storyboard deleted file mode 100644 index 4865e49..0000000 --- a/CameraButton-Example/Base.lproj/Main.storyboard +++ /dev/null @@ -1,232 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/CameraButton-Example/BasicViewController.swift b/CameraButton-Example/BasicViewController.swift deleted file mode 100644 index 8b0c92c..0000000 --- a/CameraButton-Example/BasicViewController.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// BasicViewController.swift -// CameraButton-Example -// -// Created by Рамиз Кичибеков on 26/09/2019. -// Copyright © 2019 Ramiz Kichibekov. All rights reserved. -// - -import UIKit -import CameraButton - -final class BasicViewController: UIViewController { - - // MARK: Outlets - - @IBOutlet private var cameraButton: CameraButton! - - // MARK: Actions - - @IBAction - private func didChangeSegment(_ sender: UISegmentedControl) { - cameraButton.type = CameraType(withValue: sender.selectedSegmentIndex) - } - -} diff --git a/CameraButton-Example/ButtonConfiguration.swift b/CameraButton-Example/ButtonConfiguration.swift new file mode 100644 index 0000000..c467b4b --- /dev/null +++ b/CameraButton-Example/ButtonConfiguration.swift @@ -0,0 +1,129 @@ +// +// ButtonConfiguration.swift +// CameraButton-Example +// +// Copyright © 2026 Ramiz Kichibekov. All rights reserved. +// + +import SwiftUI +import CameraButton + +/// Every tweakable `CameraButton` property, edited live in the playground sheet. +@MainActor +@Observable +final class ButtonConfiguration { + + enum Framework: String, CaseIterable, Identifiable { + case swiftUI = "SwiftUI" + case uiKit = "UIKit" + + var id: Self { self } + } + + // MARK: Properties + + var framework: Framework = .swiftUI + var style: CameraButton.Style = .glass + var glassTint: Color? + var photoColor: Color? + var videoColor: Color? + var ringColor: Color = .white + var progressColor: Color = .red + var ringLineWidth: CGFloat = 4 + var ringOffset: CGFloat = 3 + var isHapticFeedbackEnabled = true + var maxRecordingDuration: TimeInterval = 15 + + // MARK: Public methods + + func apply(to button: CameraButton) { + button.glassTintColor = glassTint.map(UIColor.init) + button.photoColor = photoColor.map(UIColor.init) ?? .clear + button.videoColor = videoColor.map(UIColor.init) ?? .clear + button.ringColor = UIColor(ringColor) + button.progressColor = UIColor(progressColor) + button.ringLineWidth = ringLineWidth + button.ringOffset = ringOffset + button.isHapticFeedbackEnabled = isHapticFeedbackEnabled + } + + func reset() { + let defaults = ButtonConfiguration() + style = defaults.style + glassTint = defaults.glassTint + photoColor = defaults.photoColor + videoColor = defaults.videoColor + ringColor = defaults.ringColor + progressColor = defaults.progressColor + ringLineWidth = defaults.ringLineWidth + ringOffset = defaults.ringOffset + isHapticFeedbackEnabled = defaults.isHapticFeedbackEnabled + maxRecordingDuration = defaults.maxRecordingDuration + } + + /// Swift code that reproduces the current configuration. + func snippet(for type: CameraType) -> String { + let properties = changedProperties() + switch framework { + case .uiKit: + var lines = ["let button = CameraButton()", + "button.type = \(type == .photo ? ".photo" : ".video")", + "button.style = \(style == .glass ? ".glass" : ".classic")"] + lines += properties.map { "button.\($0) = \($1)" } + lines.append("button.delegate = self") + + return lines.joined(separator: "\n") + case .swiftUI: + let styleLine = "style: \(style == .glass ? ".glass" : ".classic")" + var lines = type == .photo + ? ["CameraShutterButton(type: .photo, \(styleLine)) {", + " camera.capturePhoto()"] + : ["CameraShutterButton(type: .video,", + " \(styleLine),", + " isRecording: $isRecording) {", + " isRecording ? recorder.start() : recorder.stop()"] + if properties.isEmpty { + lines.append("}") + } else { + lines.append("} configure: { button in") + lines += properties.map { " button.\($0) = \($1)" } + lines.append("}") + } + lines.append(".frame(width: 76, height: 76)") + + return lines.joined(separator: "\n") + } + } + + // MARK: Private methods + + private func changedProperties() -> [(String, String)] { + let defaults = ButtonConfiguration() + var result: [(String, String)] = [] + if let glassTint { result.append(("glassTintColor", glassTint.swiftLiteral)) } + if let photoColor { result.append(("photoColor", photoColor.swiftLiteral)) } + if let videoColor { result.append(("videoColor", videoColor.swiftLiteral)) } + if ringColor != defaults.ringColor { result.append(("ringColor", ringColor.swiftLiteral)) } + if progressColor != defaults.progressColor { result.append(("progressColor", progressColor.swiftLiteral)) } + if ringLineWidth != defaults.ringLineWidth { result.append(("ringLineWidth", ringLineWidth.formatted())) } + if ringOffset != defaults.ringOffset { result.append(("ringOffset", ringOffset.formatted())) } + if !isHapticFeedbackEnabled { result.append(("isHapticFeedbackEnabled", "false")) } + + return result + } + +} + +private extension Color { + + var swiftLiteral: String { + let resolved = UIColor(self) + var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0 + resolved.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + let components = [red, green, blue].map { String(format: "%.2f", $0) } + let alphaPart = alpha < 1 ? String(format: ", alpha: %.2f", alpha) : ", alpha: 1" + + return "UIColor(red: \(components[0]), green: \(components[1]), blue: \(components[2])\(alphaPart))" + } + +} diff --git a/CameraButton-Example/CameraBackground.swift b/CameraButton-Example/CameraBackground.swift new file mode 100644 index 0000000..b47a409 --- /dev/null +++ b/CameraButton-Example/CameraBackground.swift @@ -0,0 +1,94 @@ +// +// CameraBackground.swift +// CameraButton-Example +// +// Copyright © 2026 Ramiz Kichibekov. All rights reserved. +// + +import SwiftUI +import AVFoundation + +/// Live camera preview on device, an animated scene in the Simulator. +struct CameraBackground: View { + + let model: CameraModel + + var body: some View { + switch model.source { + case .live: + CameraPreview(session: model.service.session) + case .simulated, .loading: + TimelineView(.animation) { context in + SimulatedScene(date: context.date) + } + case .denied: + ZStack { + SimulatedScene(date: .distantPast) + ContentUnavailableView { + Label("Camera access is off", systemImage: "camera.fill") + } description: { + Text("The demo works without it. Turn it on in Settings to see the button over a live preview.") + } actions: { + Button("Open Settings") { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } + } + } + } + } + +} + +/// Slowly drifting mesh gradient, so Liquid Glass has something to refract. +struct SimulatedScene: View { + + let date: Date + + var body: some View { + let t = Float(date.timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: 600)) + let drift = { (phase: Float) in 0.5 + 0.25 * sin(t * 0.4 + phase) } + MeshGradient(width: 3, + height: 3, + points: [ + [0, 0], [0.5, 0], [1, 0], + [0, 0.5], [drift(0), drift(2)], [1, 0.5], + [0, 1], [0.5, 1], [1, 1] + ], + colors: [ + .indigo, .purple, .pink, + .blue, .orange, .red, + .teal, .mint, .yellow + ]) + } + +} + +private struct CameraPreview: UIViewRepresentable { + + let session: AVCaptureSession + + func makeUIView(context: Context) -> PreviewView { + let view = PreviewView() + view.previewLayer.session = session + view.previewLayer.videoGravity = .resizeAspectFill + + return view + } + + func updateUIView(_ uiView: PreviewView, context: Context) {} + + final class PreviewView: UIView { + + override class var layerClass: AnyClass { + AVCaptureVideoPreviewLayer.self + } + + var previewLayer: AVCaptureVideoPreviewLayer { + layer as! AVCaptureVideoPreviewLayer + } + + } + +} diff --git a/CameraButton-Example/CameraButtonExampleApp.swift b/CameraButton-Example/CameraButtonExampleApp.swift new file mode 100644 index 0000000..fbcc0a7 --- /dev/null +++ b/CameraButton-Example/CameraButtonExampleApp.swift @@ -0,0 +1,20 @@ +// +// CameraButtonExampleApp.swift +// CameraButton-Example +// +// Copyright © 2026 Ramiz Kichibekov. All rights reserved. +// + +import SwiftUI + +@main +struct CameraButtonExampleApp: App { + + var body: some Scene { + WindowGroup { + CameraScreen() + .preferredColorScheme(.dark) + } + } + +} diff --git a/CameraButton-Example/CameraModel.swift b/CameraButton-Example/CameraModel.swift new file mode 100644 index 0000000..28a250d --- /dev/null +++ b/CameraButton-Example/CameraModel.swift @@ -0,0 +1,117 @@ +// +// CameraModel.swift +// CameraButton-Example +// +// Copyright © 2026 Ramiz Kichibekov. All rights reserved. +// + +import SwiftUI + +/// Drives the demo camera: live preview on device, a simulated scene everywhere else. +@MainActor +@Observable +final class CameraModel { + + enum Source { + case loading + case live + case simulated + case denied + } + + struct Capture: Identifiable { + let id = UUID() + let image: UIImage + let videoDuration: TimeInterval? + } + + // MARK: Properties + + let service = CaptureService() + private(set) var source: Source = .loading + private(set) var lastCapture: Capture? + private(set) var flashCount = 0 + private(set) var recordingStart: Date? + var isRecording = false { + didSet { + guard oldValue != isRecording else { return } + + isRecording ? startRecording() : stopRecording() + } + } + var maxRecordingDuration: TimeInterval = 15 + + private var autoStopTask: Task? + + // MARK: Public methods + + func start() async { + guard source == .loading else { return } + + #if targetEnvironment(simulator) + source = .simulated + #else + guard await CaptureService.requestAccess() else { + source = .denied + return + } + + source = await service.configure() ? .live : .simulated + #endif + } + + func capturePhoto() { + flashCount += 1 + Task { + if let image = await snapshot() { + lastCapture = Capture(image: image, videoDuration: nil) + } + } + } + + func recordingProgress(at date: Date) -> CGFloat { + guard let recordingStart else { return 0 } + + return min(date.timeIntervalSince(recordingStart) / maxRecordingDuration, 1) + } + + // MARK: Private methods + + private func startRecording() { + let start = Date() + recordingStart = start + autoStopTask?.cancel() + autoStopTask = Task { [maxRecordingDuration] in + try? await Task.sleep(for: .seconds(maxRecordingDuration)) + guard !Task.isCancelled, recordingStart == start else { return } + + isRecording = false + } + } + + private func stopRecording() { + autoStopTask?.cancel() + guard let recordingStart else { return } + + let duration = Date().timeIntervalSince(recordingStart) + self.recordingStart = nil + Task { + if let image = await snapshot() { + lastCapture = Capture(image: image, videoDuration: duration) + } + } + } + + private func snapshot() async -> UIImage? { + switch source { + case .live: + return await service.capturePhoto() + case .simulated, .denied, .loading: + let renderer = ImageRenderer(content: SimulatedScene(date: .now).frame(width: 300, height: 400)) + renderer.scale = 2 + + return renderer.uiImage + } + } + +} diff --git a/CameraButton-Example/CameraScreen.swift b/CameraButton-Example/CameraScreen.swift new file mode 100644 index 0000000..03c863a --- /dev/null +++ b/CameraButton-Example/CameraScreen.swift @@ -0,0 +1,242 @@ +// +// CameraScreen.swift +// CameraButton-Example +// +// Copyright © 2026 Ramiz Kichibekov. All rights reserved. +// + +import SwiftUI +import CameraButton + +struct CameraScreen: View { + + // MARK: Properties + + @State private var model = CameraModel() + @State private var configuration = ButtonConfiguration() + @State private var mode: CameraType = .photo + @State private var isPlaygroundPresented = false + @State private var previewedCapture: CameraModel.Capture? + @State private var flashOpacity: Double = 0 + + // MARK: Body + + var body: some View { + ZStack { + CameraBackground(model: model) + .ignoresSafeArea() + + Color.white + .opacity(flashOpacity) + .ignoresSafeArea() + .allowsHitTesting(false) + + VStack(spacing: 0) { + topBar + Spacer() + modePicker + .padding(.bottom, 20) + bottomBar + } + .padding(.horizontal, 24) + .padding(.bottom, 12) + } + .task { + await model.start() + } + .onChange(of: model.flashCount) { + flash() + } + .onChange(of: configuration.maxRecordingDuration, initial: true) { _, duration in + model.maxRecordingDuration = duration + } + .sheet(isPresented: $isPlaygroundPresented) { + PlaygroundView(configuration: configuration, type: mode) + .presentationDetents([.medium, .large]) + } + .sheet(item: $previewedCapture) { capture in + CapturePreview(capture: capture) + } + } + + // MARK: Subviews + + private var topBar: some View { + HStack { + Picker("Framework", selection: $configuration.framework) { + ForEach(ButtonConfiguration.Framework.allCases) { framework in + Text(framework.rawValue).tag(framework) + } + } + .pickerStyle(.segmented) + .frame(width: 180) + .disabled(model.isRecording) + + Spacer() + + if model.isRecording { + TimelineView(.periodic(from: .now, by: 1)) { context in + RecordingTimer(start: model.recordingStart ?? .now, now: context.date) + } + .transition(.scale.combined(with: .opacity)) + } + } + .animation(.snappy, value: model.isRecording) + .padding(.top, 8) + } + + private var modePicker: some View { + HStack(spacing: 4) { + modeButton("Photo", type: .photo) + modeButton("Video", type: .video) + } + .padding(4) + .glassBackground(in: Capsule()) + .disabled(model.isRecording) + .opacity(model.isRecording ? 0 : 1) + .animation(.snappy, value: model.isRecording) + } + + private func modeButton(_ title: LocalizedStringKey, type: CameraType) -> some View { + Button { + withAnimation(.snappy) { + mode = type + } + } label: { + Text(title) + .font(.subheadline.weight(.semibold)) + .textCase(.uppercase) + .foregroundStyle(mode == type ? Color.yellow : .white) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background { + if mode == type { + Capsule().fill(.white.opacity(0.15)) + } + } + } + .buttonStyle(.plain) + .accessibilityAddTraits(mode == type ? .isSelected : []) + } + + private var bottomBar: some View { + HStack { + thumbnail + Spacer() + TimelineView(.animation(paused: !model.isRecording)) { context in + ShutterView(model: model, + configuration: configuration, + type: mode, + progress: model.recordingProgress(at: context.date)) + } + .frame(width: 76, height: 76) + Spacer() + GlassIconButton(systemImage: "slider.horizontal.3", accessibilityLabel: "Playground") { + isPlaygroundPresented = true + } + .frame(width: 56, height: 56) + .disabled(model.isRecording) + } + } + + private var thumbnail: some View { + Button { + previewedCapture = model.lastCapture + } label: { + ZStack(alignment: .bottomTrailing) { + if let capture = model.lastCapture { + Image(uiImage: capture.image) + .resizable() + .scaledToFill() + .id(capture.id) + .transition(.scale(scale: 0.6).combined(with: .opacity)) + if capture.videoDuration != nil { + Image(systemName: "video.fill") + .font(.caption2) + .foregroundStyle(.white) + .padding(4) + } + } else { + Color.clear + } + } + .frame(width: 56, height: 56) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .glassBackground(in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .animation(.bouncy, value: model.lastCapture?.id) + } + .buttonStyle(.plain) + .disabled(model.lastCapture == nil || model.isRecording) + .accessibilityLabel("Last capture") + } + + // MARK: Private methods + + private func flash() { + flashOpacity = 0.85 + withAnimation(.easeOut(duration: 0.35)) { + flashOpacity = 0 + } + } + +} + +// MARK: - RecordingTimer + +private struct RecordingTimer: View { + + let start: Date + let now: Date + + var body: some View { + let seconds = max(Int(now.timeIntervalSince(start)), 0) + HStack(spacing: 6) { + Circle() + .fill(.red) + .frame(width: 8, height: 8) + Text(Duration.seconds(seconds), format: .time(pattern: .minuteSecond)) + .font(.subheadline.monospacedDigit().weight(.semibold)) + .foregroundStyle(.white) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .glassBackground(in: Capsule()) + .accessibilityElement(children: .combine) + .accessibilityLabel("Recording") + } + +} + +// MARK: - CapturePreview + +private struct CapturePreview: View { + + let capture: CameraModel.Capture + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + Image(uiImage: capture.image) + .resizable() + .scaledToFit() + .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous)) + .padding() + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { + dismiss() + } + } + } + } + } + + private var title: String { + guard let duration = capture.videoDuration else { return "Photo" } + + return "Video · " + Duration.seconds(Int(duration)).formatted(.time(pattern: .minuteSecond)) + } + +} diff --git a/CameraButton-Example/CaptureService.swift b/CameraButton-Example/CaptureService.swift new file mode 100644 index 0000000..e49e46b --- /dev/null +++ b/CameraButton-Example/CaptureService.swift @@ -0,0 +1,93 @@ +// +// CaptureService.swift +// CameraButton-Example +// +// Copyright © 2026 Ramiz Kichibekov. All rights reserved. +// + +import AVFoundation +import UIKit + +/// Minimal photo capture pipeline. All session work runs on a private serial queue. +final class CaptureService: NSObject, @unchecked Sendable { + + // MARK: Properties + + let session = AVCaptureSession() + + private let queue = DispatchQueue(label: "CameraButtonExample.capture") + private let photoOutput = AVCapturePhotoOutput() + private var photoContinuation: CheckedContinuation? + + // MARK: Public methods + + static func requestAccess() async -> Bool { + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: true + case .notDetermined: await AVCaptureDevice.requestAccess(for: .video) + default: false + } + } + + /// Configures the back camera. Returns `false` when there is no camera, e.g. in the Simulator. + func configure() async -> Bool { + await withCheckedContinuation { continuation in + queue.async { + guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back), + let input = try? AVCaptureDeviceInput(device: device) else { + continuation.resume(returning: false) + return + } + + self.session.beginConfiguration() + self.session.sessionPreset = .photo + if self.session.canAddInput(input) { + self.session.addInput(input) + } + if self.session.canAddOutput(self.photoOutput) { + self.session.addOutput(self.photoOutput) + } + self.session.commitConfiguration() + self.session.startRunning() + continuation.resume(returning: true) + } + } + } + + func stop() { + queue.async { + self.session.stopRunning() + } + } + + func capturePhoto() async -> UIImage? { + await withCheckedContinuation { continuation in + queue.async { + guard self.session.isRunning, self.photoContinuation == nil else { + continuation.resume(returning: nil) + return + } + + self.photoContinuation = continuation + self.photoOutput.capturePhoto(with: AVCapturePhotoSettings(), delegate: self) + } + } + } + +} + +// MARK: - AVCapturePhotoCaptureDelegate + +extension CaptureService: AVCapturePhotoCaptureDelegate { + + func photoOutput(_ output: AVCapturePhotoOutput, + didFinishProcessingPhoto photo: AVCapturePhoto, + error: Error?) { + let image = photo.fileDataRepresentation().flatMap(UIImage.init(data:)) + queue.async { + self.photoContinuation?.resume(returning: image) + self.photoContinuation = nil + } + } + +} diff --git a/CameraButton-Example/CustomViewController.swift b/CameraButton-Example/CustomViewController.swift deleted file mode 100644 index 0e29033..0000000 --- a/CameraButton-Example/CustomViewController.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// CustomViewController.swift -// CameraButton-Example -// -// Created by Рамиз Кичибеков on 12/08/2020. -// Copyright © 2020 Ramiz Kichibekov. All rights reserved. -// - -import UIKit -import CameraButton - -final class CustomViewController: UIViewController { - - // MARK: Properties - - private lazy var cameraButton: CameraButton = { - let frame = CGRect(x: .zero, y: .zero, width: 60, height: 60) - let button = CameraButton(frame: frame) - button.photoColor = .black - button.videoColor = .orange - button.center = view.center - - return button - }() - - // MARK: Life cycle - - override func viewDidLoad() { - super.viewDidLoad() - - view.addSubview(cameraButton) - } - - // MARK: Actions - - @IBAction - private func didChangeSegment(_ sender: UISegmentedControl) { - cameraButton.type = CameraType(withValue: sender.selectedSegmentIndex) - } - -} diff --git a/CameraButton-Example/Glass.swift b/CameraButton-Example/Glass.swift new file mode 100644 index 0000000..5dbbdbc --- /dev/null +++ b/CameraButton-Example/Glass.swift @@ -0,0 +1,48 @@ +// +// Glass.swift +// CameraButton-Example +// +// Copyright © 2026 Ramiz Kichibekov. All rights reserved. +// + +import SwiftUI + +extension View { + + /// Liquid Glass on iOS 26 and later, a material on earlier systems. + @ViewBuilder + func glassBackground(in shape: S, interactive: Bool = false) -> some View { + #if compiler(>=6.2) + if #available(iOS 26.0, *) { + glassEffect(interactive ? .regular.interactive() : .regular, in: shape) + } else { + background(.ultraThinMaterial, in: shape) + } + #else + background(.ultraThinMaterial, in: shape) + #endif + } + +} + +/// Round glass button with an SF Symbol. +struct GlassIconButton: View { + + let systemImage: String + let accessibilityLabel: String + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: systemImage) + .font(.title3.weight(.semibold)) + .foregroundStyle(.white) + .frame(width: 48, height: 48) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .glassBackground(in: Circle(), interactive: true) + .accessibilityLabel(accessibilityLabel) + } + +} diff --git a/CameraButton-Example/GlassViewController.swift b/CameraButton-Example/GlassViewController.swift deleted file mode 100644 index 2cfdbb9..0000000 --- a/CameraButton-Example/GlassViewController.swift +++ /dev/null @@ -1,164 +0,0 @@ -// -// 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-Example/Info.plist b/CameraButton-Example/Info.plist deleted file mode 100644 index 2a3483c..0000000 --- a/CameraButton-Example/Info.plist +++ /dev/null @@ -1,64 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - 1.0 - CFBundleVersion - 1 - LSRequiresIPhoneOS - - UIApplicationSceneManifest - - UIApplicationSupportsMultipleScenes - - UISceneConfigurations - - UIWindowSceneSessionRoleApplication - - - UISceneConfigurationName - Default Configuration - UISceneDelegateClassName - $(PRODUCT_MODULE_NAME).SceneDelegate - UISceneStoryboardFile - Main - - - - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UIRequiredDeviceCapabilities - - armv7 - - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - - diff --git a/CameraButton-Example/PlaygroundView.swift b/CameraButton-Example/PlaygroundView.swift new file mode 100644 index 0000000..6b29ed0 --- /dev/null +++ b/CameraButton-Example/PlaygroundView.swift @@ -0,0 +1,131 @@ +// +// PlaygroundView.swift +// CameraButton-Example +// +// Copyright © 2026 Ramiz Kichibekov. All rights reserved. +// + +import SwiftUI +import CameraButton + +/// Live editor for every `CameraButton` property, with the matching Swift code. +struct PlaygroundView: View { + + // MARK: Properties + + @Bindable var configuration: ButtonConfiguration + let type: CameraType + @Environment(\.dismiss) private var dismiss + @State private var didCopy = false + + // MARK: Body + + var body: some View { + NavigationStack { + Form { + Section("Style") { + Picker("Style", selection: $configuration.style) { + Text("Glass").tag(CameraButton.Style.glass) + Text("Classic").tag(CameraButton.Style.classic) + } + .pickerStyle(.segmented) + if configuration.style == .glass { + OptionalColorRow(title: "Glass tint", color: $configuration.glassTint, fallback: .blue.opacity(0.3)) + } + } + + Section("Colors") { + OptionalColorRow(title: "Photo fill", color: $configuration.photoColor, fallback: .white) + OptionalColorRow(title: "Video fill", color: $configuration.videoColor, fallback: .red) + ColorPicker("Ring", selection: $configuration.ringColor) + ColorPicker("Recording progress", selection: $configuration.progressColor) + } + + Section("Ring") { + LabeledSlider(title: "Line width", value: $configuration.ringLineWidth, range: 1...8) + LabeledSlider(title: "Offset", value: $configuration.ringOffset, range: 0...8) + } + + Section("Behavior") { + Toggle("Haptic feedback", isOn: $configuration.isHapticFeedbackEnabled) + Stepper(value: $configuration.maxRecordingDuration, in: 5...60, step: 5) { + LabeledContent("Max video length", + value: Duration.seconds(configuration.maxRecordingDuration), + format: .time(pattern: .minuteSecond)) + } + } + + Section { + Text(configuration.snippet(for: type)) + .font(.caption.monospaced()) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + Button(didCopy ? "Copied" : "Copy Code", systemImage: didCopy ? "checkmark" : "doc.on.doc") { + UIPasteboard.general.string = configuration.snippet(for: type) + didCopy = true + } + } header: { + Text("\(configuration.framework.rawValue) code") + } + } + .navigationTitle("Playground") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Reset") { + configuration.reset() + } + } + ToolbarItem(placement: .confirmationAction) { + Button("Done") { + dismiss() + } + } + } + .onChange(of: configuration.snippet(for: type)) { + didCopy = false + } + } + } + +} + +// MARK: - Rows + +private struct OptionalColorRow: View { + + let title: LocalizedStringKey + @Binding var color: Color? + let fallback: Color + + var body: some View { + Toggle(isOn: Binding(get: { color != nil }, + set: { color = $0 ? fallback : nil })) { + HStack { + Text(title) + Spacer() + if color != nil { + ColorPicker(title, selection: Binding(get: { color ?? fallback }, + set: { color = $0 })) + .labelsHidden() + } + } + } + } + +} + +private struct LabeledSlider: View { + + let title: LocalizedStringKey + @Binding var value: CGFloat + let range: ClosedRange + + var body: some View { + VStack(alignment: .leading) { + LabeledContent(title, value: value, format: .number.precision(.fractionLength(1))) + Slider(value: $value, in: range, step: 0.5) + } + } + +} diff --git a/CameraButton-Example/SceneDelegate.swift b/CameraButton-Example/SceneDelegate.swift deleted file mode 100644 index f2c4b45..0000000 --- a/CameraButton-Example/SceneDelegate.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// SceneDelegate.swift -// CameraButton-Example -// -// Created by Рамиз Кичибеков on 26/09/2019. -// Copyright © 2019 Ramiz Kichibekov. All rights reserved. -// - -import UIKit - -final class SceneDelegate: UIResponder, UIWindowSceneDelegate { - - var window: UIWindow? - - func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { - } -} diff --git a/CameraButton-Example/ShutterView.swift b/CameraButton-Example/ShutterView.swift new file mode 100644 index 0000000..12ba1ba --- /dev/null +++ b/CameraButton-Example/ShutterView.swift @@ -0,0 +1,89 @@ +// +// ShutterView.swift +// CameraButton-Example +// +// Copyright © 2026 Ramiz Kichibekov. All rights reserved. +// + +import SwiftUI +import CameraButton + +/// The same configured button, built either with the SwiftUI wrapper or with the UIKit API. +struct ShutterView: View { + + @Bindable var model: CameraModel + let configuration: ButtonConfiguration + let type: CameraType + let progress: CGFloat + + var body: some View { + switch configuration.framework { + case .swiftUI: + CameraShutterButton(type: type, + style: configuration.style, + isRecording: $model.isRecording, + recordingProgress: progress) { + if type == .photo { + model.capturePhoto() + } + } configure: { button in + configuration.apply(to: button) + } + case .uiKit: + UIKitShutter(model: model, configuration: configuration, type: type, progress: progress) + } + } + +} + +/// Hosts `CameraButton` exactly as a UIKit screen would use it: properties plus `CameraButtonDelegate`. +private struct UIKitShutter: UIViewRepresentable { + + let model: CameraModel + let configuration: ButtonConfiguration + let type: CameraType + let progress: CGFloat + + func makeUIView(context: Context) -> CameraButton { + let button = CameraButton() + button.delegate = context.coordinator + + return button + } + + func updateUIView(_ button: CameraButton, context: Context) { + configuration.apply(to: button) + button.style = configuration.style + if button.type != type { + button.type = type + } + if type == .video, button.isSelected != model.isRecording { + button.isSelected = model.isRecording + } + if button.recordingProgress != progress { + button.recordingProgress = progress + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(model: model) + } + + final class Coordinator: CameraButtonDelegate { + + private let model: CameraModel + + init(model: CameraModel) { + self.model = model + } + + func didTapButton(_ button: CameraButton) { + switch button.type { + case .photo: model.capturePhoto() + case .video: model.isRecording = button.isSelected + } + } + + } + +} diff --git a/CameraButton.xcodeproj/project.pbxproj b/CameraButton.xcodeproj/project.pbxproj index 82f7f46..cd8a05f 100644 --- a/CameraButton.xcodeproj/project.pbxproj +++ b/CameraButton.xcodeproj/project.pbxproj @@ -12,14 +12,6 @@ 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 */; }; - 4BE099C9233D339B00AE7670 /* BasicViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BE099C8233D339B00AE7670 /* BasicViewController.swift */; }; - 4BE099CC233D339B00AE7670 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 4BE099CA233D339B00AE7670 /* Main.storyboard */; }; - 4BE099CE233D339D00AE7670 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4BE099CD233D339D00AE7670 /* Assets.xcassets */; }; - 4BE099D1233D339D00AE7670 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 4BE099CF233D339D00AE7670 /* LaunchScreen.storyboard */; }; 4BE099D7233D36C400AE7670 /* CameraButton.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4BE099B1233D331200AE7670 /* CameraButton.framework */; }; 4BE099D8233D36C400AE7670 /* CameraButton.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 4BE099B1233D331200AE7670 /* CameraButton.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ @@ -55,19 +47,18 @@ 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; }; - 4BE099C4233D339B00AE7670 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 4BE099C6233D339B00AE7670 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; - 4BE099C8233D339B00AE7670 /* BasicViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicViewController.swift; sourceTree = ""; }; - 4BE099CB233D339B00AE7670 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; - 4BE099CD233D339D00AE7670 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - 4BE099D0233D339D00AE7670 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; - 4BE099D2233D339D00AE7670 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 4BE099C3233D339B00AE7670 /* CameraButton-Example */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = "CameraButton-Example"; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ 4BE099AE233D331200AE7670 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; @@ -135,22 +126,6 @@ name = Products; sourceTree = ""; }; - 4BE099C3233D339B00AE7670 /* CameraButton-Example */ = { - isa = PBXGroup; - children = ( - 4BE099C4233D339B00AE7670 /* AppDelegate.swift */, - 4BE099C6233D339B00AE7670 /* SceneDelegate.swift */, - 4BE099C8233D339B00AE7670 /* BasicViewController.swift */, - 4BC6F2EE257F9D3000691CE9 /* CustomViewController.swift */, - 4BF1A0042E9A000000C0FFEE /* GlassViewController.swift */, - 4BE099CA233D339B00AE7670 /* Main.storyboard */, - 4BE099CD233D339D00AE7670 /* Assets.xcassets */, - 4BE099CF233D339D00AE7670 /* LaunchScreen.storyboard */, - 4BE099D2233D339D00AE7670 /* Info.plist */, - ); - path = "CameraButton-Example"; - sourceTree = ""; - }; 4BE099D6233D36C400AE7670 /* Frameworks */ = { isa = PBXGroup; children = ( @@ -204,6 +179,9 @@ dependencies = ( 4BE099DA233D36C400AE7670 /* PBXTargetDependency */, ); + fileSystemSynchronizedGroups = ( + 4BE099C3233D339B00AE7670 /* CameraButton-Example */, + ); name = "CameraButton-Example"; productName = "CameraButton-Example"; productReference = 4BE099C2233D339B00AE7670 /* CameraButton-Example.app */; @@ -261,9 +239,6 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 4BE099D1233D339D00AE7670 /* LaunchScreen.storyboard in Resources */, - 4BE099CE233D339D00AE7670 /* Assets.xcassets in Resources */, - 4BE099CC233D339B00AE7670 /* Main.storyboard in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -285,11 +260,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 4BE099C9233D339B00AE7670 /* BasicViewController.swift in Sources */, - 4BE099C5233D339B00AE7670 /* AppDelegate.swift in Sources */, - 4BE099C7233D339B00AE7670 /* SceneDelegate.swift in Sources */, - 4BC6F2EF257F9D3000691CE9 /* CustomViewController.swift in Sources */, - 4BF1A0052E9A000000C0FFEE /* GlassViewController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -303,25 +273,6 @@ }; /* End PBXTargetDependency section */ -/* Begin PBXVariantGroup section */ - 4BE099CA233D339B00AE7670 /* Main.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 4BE099CB233D339B00AE7670 /* Base */, - ); - name = Main.storyboard; - sourceTree = ""; - }; - 4BE099CF233D339D00AE7670 /* LaunchScreen.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 4BE099D0233D339D00AE7670 /* Base */, - ); - name = LaunchScreen.storyboard; - sourceTree = ""; - }; -/* End PBXVariantGroup section */ - /* Begin XCBuildConfiguration section */ 4BE099B7233D331200AE7670 /* Debug */ = { isa = XCBuildConfiguration; @@ -525,9 +476,18 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 8AD8N54BY6; - INFOPLIST_FILE = "CameraButton-Example/Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = CameraButton; + INFOPLIST_KEY_NSCameraUsageDescription = "The camera preview shows how CameraButton looks over a live scene. Nothing is saved or uploaded."; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + MARKETING_VERSION = 1.0.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -548,9 +508,18 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 8AD8N54BY6; - INFOPLIST_FILE = "CameraButton-Example/Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = CameraButton; + INFOPLIST_KEY_NSCameraUsageDescription = "The camera preview shows how CameraButton looks over a live scene. Nothing is saved or uploaded."; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + MARKETING_VERSION = 1.0.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/README.md b/README.md index d7e1cd0..8c0ee52 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ - Swift 6.0+ ## Preview - +CameraButton example app: photo capture, then video recording with a progress ring ## Installation @@ -95,6 +95,15 @@ The ring resets when recording stops. The button supports VoiceOver labels, Dynamic colors and Reduce Motion out of the box. +## Example app + +Open `CameraButton.xcodeproj` and run the `CameraButton-Example` scheme (iOS 18+). It is a SwiftUI camera screen with: + +- a live camera preview on device and an animated scene in the Simulator; +- photo and video modes with a recording timer and a 15 s limit; +- a Playground sheet that edits every button property live and shows the matching Swift code; +- a SwiftUI / UIKit switch that renders the same button through `CameraShutterButton` or through `CameraButton` with a delegate. + ## Author ramiz69, ramiz161@icloud.com diff --git a/Sources/CameraButton.swift b/Sources/CameraButton.swift index cb34335..3655785 100644 --- a/Sources/CameraButton.swift +++ b/Sources/CameraButton.swift @@ -47,6 +47,8 @@ open class CameraButton: UIButton { /// Tint of the Liquid Glass disc. Applies to the `glass` style only. public var glassTintColor: UIColor? { didSet { + guard oldValue != glassTintColor else { return } + updateStyle() } } @@ -58,12 +60,16 @@ open class CameraButton: UIButton { public var photoColor: UIColor = .clear { didSet { + guard oldValue != photoColor else { return } + updateFillColor(configureColor(), animated: false) } } public var videoColor: UIColor = .clear { didSet { + guard oldValue != videoColor else { return } + updateFillColor(configureColor(), animated: false) } } @@ -72,18 +78,24 @@ open class CameraButton: UIButton { public var ringOffset: CGFloat = 3 { didSet { + guard oldValue != ringOffset else { return } + updateRing() } } public var ringLineWidth: CGFloat = 4 { didSet { + guard oldValue != ringLineWidth else { return } + updateRing() } } public var ringColor: UIColor = .white { didSet { + guard oldValue != ringColor else { return } + setNeedsDisplay() } } @@ -92,6 +104,8 @@ open class CameraButton: UIButton { public var progressColor: UIColor = .systemRed { didSet { + guard oldValue != progressColor else { return } + updateProgressColor() } } @@ -111,6 +125,8 @@ open class CameraButton: UIButton { public var innerLargeCircleOffset: CGFloat = 8 { didSet { + guard oldValue != innerLargeCircleOffset else { return } + updateInnerPath(animated: false) } } @@ -119,6 +135,8 @@ open class CameraButton: UIButton { public var innerSmallCircleOffset: CGFloat = 13 { didSet { + guard oldValue != innerSmallCircleOffset else { return } + updateInnerPath(animated: false) } } @@ -127,12 +145,16 @@ open class CameraButton: UIButton { public var innerSquare: CGFloat = 17 { didSet { + guard oldValue != innerSquare else { return } + updateInnerPath(animated: false) } } public var squareCornerRadius: CGFloat = 8 { didSet { + guard oldValue != squareCornerRadius else { return } + updateInnerPath(animated: false) } } diff --git a/Sources/SwiftUI/CameraShutterButton.swift b/Sources/SwiftUI/CameraShutterButton.swift index 186756b..a3420f6 100644 --- a/Sources/SwiftUI/CameraShutterButton.swift +++ b/Sources/SwiftUI/CameraShutterButton.swift @@ -15,6 +15,16 @@ import SwiftUI /// } /// .frame(width: 72, height: 72) /// ``` +/// +/// Use `configure` to set any other `CameraButton` property: +/// +/// ```swift +/// CameraShutterButton(type: .photo) { +/// camera.capture() +/// } configure: { button in +/// button.ringColor = .systemYellow +/// } +/// ``` public struct CameraShutterButton: UIViewRepresentable { // MARK: Properties @@ -23,6 +33,7 @@ public struct CameraShutterButton: UIViewRepresentable { private let style: CameraButton.Style private let recordingProgress: CGFloat private let action: () -> Void + private let configure: (CameraButton) -> Void @Binding private var isRecording: Bool // MARK: Life cycle @@ -31,12 +42,14 @@ public struct CameraShutterButton: UIViewRepresentable { style: CameraButton.Style = .classic, isRecording: Binding = .constant(false), recordingProgress: CGFloat = 0, - action: @escaping () -> Void = {}) { + action: @escaping () -> Void = {}, + configure: @escaping (CameraButton) -> Void = { _ in }) { self.type = type self.style = style self._isRecording = isRecording self.recordingProgress = recordingProgress self.action = action + self.configure = configure } // MARK: UIViewRepresentable @@ -52,6 +65,7 @@ public struct CameraShutterButton: UIViewRepresentable { public func updateUIView(_ button: CameraButton, context: Context) { context.coordinator.parent = self + configure(button) button.style = style if button.type != type { button.type = type diff --git a/example.gif b/example.gif index 1ffae9e..896de6b 100644 Binary files a/example.gif and b/example.gif differ