Initial commit of local MijickCamera package

This commit is contained in:
Matt Bruce 2026-01-04 09:41:00 -06:00
commit 55940f0d52
116 changed files with 5705 additions and 0 deletions

1
.github/CODEOWNERS vendored Normal file
View File

@ -0,0 +1 @@
* @FulcrumOne

128
.github/CODE_OF_CONDUCT.md vendored Normal file
View File

@ -0,0 +1,128 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
team@mijick.com.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.

1
.github/CONTRIBUTING.md vendored Normal file
View File

@ -0,0 +1 @@
Coming soon...

14
.github/FUNDING.yml vendored Normal file
View File

@ -0,0 +1,14 @@
# These are supported funding model platforms
github: mijick
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: mijick
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

5
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: ❓ Help and Support Discord Channel
url: https://discord.com/invite/dT5V7nm5SC
about: Please ask and answer questions here

View File

@ -0,0 +1,18 @@
---
name: "\U0001F680 Feature Request"
about: If you have a feature request
title: "[FREQ]"
labels: 'feature'
projects: "Mijick/15"
assignees: FulcrumOne
---
## Context
What are you trying to do and how would you want to do it differently? Is it something you currently you cannot do? Is this related to an issue/problem?
## Alternatives
Can you achieve the same result doing it in an alternative way? Is the alternative considerable?
## If the feature request is approved, would you be willing to submit a PR?
Yes / No _(Help can be provided if you need assistance submitting a PR)_

View File

@ -0,0 +1,42 @@
---
name: "\U0001F99F Bug Report"
about: If something isn't working
title: "[BUG]"
labels: 'bug'
projects: "Mijick/15"
assignees: FulcrumOne, jay-jay-lama
---
## Prerequisites
- [ ] I checked the [documentation](https://github.com/Mijick/Camera/wiki) and found no answer
- [ ] I checked to make sure that this issue has not already been filed
## Expected Behavior
Please describe the behavior you are expecting
## Current Behavior
What is the current behavior?
## Steps to Reproduce
Please provide detailed steps for reproducing the issue.
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
## Code Sample
If you can, please include a code sample that we can use to debug the bug.
## Screenshots
If applicable, add screenshots to help explain your problem.
## Context
Please provide any relevant information about your setup. This is important in case the issue is not reproducible except for under certain conditions.
| Name | Version |
| ------| ---------|
| SDK | e.g. 3.0.0 |
| Xcode | e.g. 14.0 |
| Operating System | e.g. iOS 18.0 |
| Device | e.g. iPhone 14 Pro |

View File

@ -0,0 +1,14 @@
name: Publish to Cocoapods
on:
deployment:
workflow_dispatch:
jobs:
build:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- name: Publish to Cocoapods
env:
COCOAPODS_TRUNK_TOKEN: ${{ secrets.COCOAPODS_TRUNK_TOKEN }}
run: |
pod trunk push MijickCamera.podspec

14
.github/workflows/run-tests.yml vendored Normal file
View File

@ -0,0 +1,14 @@
name: Run Tests
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
workflow_dispatch:
jobs:
build:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- name: Run tests
run: xcodebuild test -scheme MijickCamera -destination 'platform=iOS Simulator,OS=18.0,name=iPhone 16 Pro'

11
.gitignore vendored Normal file
View File

@ -0,0 +1,11 @@
.DS_Store
/.build
/Packages
xcuserdata/
DerivedData/
.swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc
*.xcscheme
*.plist
.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist

201
LICENSE Normal file
View File

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright ©2024 Mijick
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

21
MijickCamera.podspec Normal file
View File

@ -0,0 +1,21 @@
Pod::Spec.new do |s|
s.name = 'MijickCamera'
s.summary = 'Significantly reduces implementation time and effort. Keeps your code clean.'
s.description = <<-DESC
Camera made simple. The ultimate camera library that significantly reduces implementation time and effort. Written with and for SwiftUI.
DESC
s.version = '3.0.1'
s.ios.deployment_target = '14.0'
s.swift_version = '6.0'
s.source_files = 'Sources/**/*.{swift}'
s.resources = 'Sources/Internal/Assets/*.{xcassets, json}'
s.dependency 'MijickTimer'
s.frameworks = 'SwiftUI', 'Foundation', 'AVKit', 'AVFoundation', 'MijickTimer'
s.homepage = 'https://github.com/Mijick/Camera.git'
s.license = { :type => 'Apache License 2.0', :file => 'LICENSE' }
s.author = { 'Tomasz Kurylik from Mijick' => 'tomasz.kurylik@mijick.com' }
s.source = { :git => 'https://github.com/Mijick/Camera.git', :tag => s.version.to_s }
end

22
Package.swift Normal file
View File

@ -0,0 +1,22 @@
// swift-tools-version: 6.0
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "MijickCamera",
platforms: [
.iOS(.v17)
],
products: [
.library(name: "MijickCamera", targets: ["MijickCamera"]),
],
dependencies: [
.package(url: "https://github.com/Mijick/Timer", exact: "2.0.0")
],
targets: [
.target(name: "MijickCamera", dependencies: [.product(name: "MijickTimer", package: "Timer")], path: "Sources", resources: [.process("Internal/Assets")]),
.testTarget(name: "MijickCameraTests", dependencies: ["MijickCamera"], path: "Tests")
],
swiftLanguageModes: [.v6]
)

226
README.md Normal file
View File

@ -0,0 +1,226 @@
<!--Hero Image-->
<p align="center">
<picture>
<img alt="MijickCamera Hero" src="https://github.com/Mijick/Assets/blob/main/Camera/hero.png" width="100%">
</picture>
</p>
<!--Text Header-->
<p>
<h3 align="center">Camera made simple</h3>
<p align="center">Significantly reduces implementation time and effort. Keeps your code clean.</p>
</p>
<!--Links: Demo, Wiki, Roadmap-->
<p align="center">
<a href="https://link.mijick.com/camera-demo" rel="nofollow"><b>Try demo we prepared</b></a>
|
<a href="https://link.mijick.com/camera-wiki" rel="nofollow"><b>Framework documentation</b></a>
|
<a href="https://link.mijick.com/camera-roadmap" rel="nofollow"><b>Roadmap</b></a>
</p>
<br>
<!--Labels-->
<p align="center">
<img alt="Labels" src="https://github.com/Mijick/Assets/blob/main/Camera/labels.svg"/>
</p>
<br>
<!--GIFs-->
<table>
<thead>
<tr>
<th>Camera Position</th>
<th>Media Capturing</th>
<th>Gestures</th>
<th>Filters</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<img src="https://github.com/Mijick/Assets/blob/main/Camera/Gifs/Camera-1.gif"/>
</td>
<td>
<img src="https://github.com/Mijick/Assets/blob/main/Camera/Gifs/Camera-2.gif"/>
</td>
<td>
<img src="https://github.com/Mijick/Assets/blob/main/Camera/Gifs/Camera-3.gif"/>
</td>
<td>
<img src="https://github.com/Mijick/Assets/blob/main/Camera/Gifs/Camera-4.gif"/>
</td>
</tr>
</tbody>
</table>
<br><br>
<!--Buttons-->
<p>
<!--Website-->
<a href="https://link.mijick.com/app">
<img alt="Visit our Website" src="https://github.com/Mijick/Assets/blob/main/Common/Buttons/website.png" height="40px">
</a>
<!--Discord-->
<a href="https://link.mijick.com/discord">
<img alt="Join us on Discord" src="https://github.com/Mijick/Assets/blob/main/Common/Buttons/discord.png" height="40px">
</a>
<!--Linkedin-->
<a href="https://link.mijick.com/linkedin">
<img alt="Follow us on LinkedIn" src="https://github.com/Mijick/Assets/blob/main/Common/Buttons/linkedin.png" height="40px">
</a>
<!--GitHub-->
<a href="https://link.mijick.com/github">
<img alt="See our other frameworks" src="https://github.com/Mijick/Assets/blob/main/Common/Buttons/github.png" height="40px">
</a>
<!--Medium-->
<a href="https://link.mijick.com/medium">
<img alt="Read us on Medium" src="https://github.com/Mijick/Assets/blob/main/Common/Buttons/medium.png" height="40px">
</a>
<!--Buymeacoffee-->
<a href="https://link.mijick.com/buymeacoffee">
<img alt="Buy us a coffee" src="https://github.com/Mijick/Assets/blob/main/Common/Buttons/buymeacoffee.png" height="40px">
</a>
</p>
<!--Features-->
# ✨ Features
<table><tbody>
<tr>
<td>🙏🏻</td>
<td>Automatically handles permissions</td>
</tr>
<tr>
<td>🖼️</td>
<td>Image capture</td>
</tr>
<tr>
<td>🎬️</td>
<td>Video capture (with or without sound)</td>
</tr>
<tr>
<td>📸</td>
<td>Camera position changes</td>
</tr>
<tr>
<td>🔍️</td>
<td>Supports manual zoom</td>
</tr>
<tr>
<td>👁️</td>
<td>Supports manual focus</td>
</tr>
<tr>
<td>🎞️</td>
<td>Changeable frame rate</td>
</tr>
<tr>
<td>📺️</td>
<td>Changeable camera resolution</td>
</tr>
<tr>
<td>🙈</td>
<td>Camera filters</td>
</tr>
<tr>
<td>🔦</td>
<td>Torch</td>
</tr>
<tr>
<td>📸</td>
<td>Flash</td>
</tr>
<tr>
<td>⏱️</td>
<td>Other camera settings (exposure duration, target bias, ISO, HDR mode and more)</td>
</tr>
<tr>
<td>☢️</td>
<td>Displays error screen if permissions are not granted</td>
</tr>
<tr>
<td>🖼️</td>
<td>Displays captured media screen</td>
</tr>
<tr>
<td>📱</td>
<td>Modern and minimalistic UI</td>
</tr>
<tr>
<td>🕺</td>
<td>Beautiful animations</td>
</tr>
<tr>
<td>🚧</td>
<td>Fully customizable screens</td>
</tr>
<tr>
<td>🤏🏼</td>
<td>Gestures support</td>
</tr>
<tr>
<td>📲</td>
<td>Blocks screen orientation change</td>
</tr>
<tr>
<td>⚡️</td>
<td>Supports Swift 6</td>
</tr>
<tr>
<td>🚀</td>
<td>... and others</td>
</tr>
</tbody></table>
<!--Description-->
# ☀️ Why MijickCamera?
The main problem we wanted to solve was the complexity of implementing camera into Swift projects; to get a camera view, you either have to accept a number of trade-offs or spend hours wrestling with the complexity of the AVKit framework. Here is why we think we have successfully solved the problem:
<p>
<h3>The power of simplicity</h3>
<p>Thanks to a modern and minimalistic UI and a thoughtfully designed public API, the most common use cases can be solved with just a few lines of code.</p>
<img alt="Code Example 1" src="https://github.com/Mijick/Assets/blob/main/Camera/Code/power-of-simplicity.png" width="100%">
</p>
<p>
<h3>Three in one</h3>
<p>MCamera contains three screens - Error Screen, Captured Media Screen and Camera Screen - making the process of handling camera states super easy. Moreover, MijickCamera automatically manages the entire workflow, from requesting camera permissions to displaying the results of camera captures!</p>
<img alt="Code Example 4" src="https://github.com/Mijick/Assets/blob/main/Camera/Code/three-in-one.png" width="100%">
</p>
<p>
<h3>Engineered for limitless creativity</h3>
<p>Every application is a special one, and we at Mijick know this very well, thus we have given you the possibility to customize each of the three screens that constitute MCamera.</p>
<img alt="Code Example 1" src="https://github.com/Mijick/Assets/blob/main/Camera/Code/limitless-creativity.png" width="100%">
</p>
### There is much more besides:
- Advanced camera controls.
- Gesture support.
- Thoroughly designed animations.
- Supports Swift 6.0.
- ... and much more.
<!--Documentation-->
# 🚀 How to use it?
Visit the framework's [documentation page](https://link.mijick.com/camera-wiki) to learn how to integrate your project with **MijickCamera**.
<!--Community-->
# 🍀 Community
Join the welcoming community of developers on [Discord](https://link.mijick.com/discord).
<!--Contribution-->
# 🌼 Contribute
To contribute a feature or idea to **MijickCamera**, create an [issue](https://github.com/Mijick/Camera/issues/new?assignees=FulcrumOne&labels=state%3A+inactive%2C+type%3A+feature&projects=&template=🚀-feature-request.md&title=%5BFREQ%5D) explaining your idea or bring it up on [Discord](https://discord.com/invite/dT5V7nm5SC). <br>
If you find a bug, please create an [issue](https://github.com/Mijick/Camera/issues/new?assignees=FulcrumOne%2C+jay-jay-lama&labels=state%3A+inactive%2C+type%3A+bug&projects=&template=🦟-bug-report.md&title=%5BBUG%5D). <br>
If you would like to contribute, please refer to the [Contribution Guidelines](https://github.com/Mijick/Camera/blob/main/.github/CONTRIBUTING.md).
<!--Sponsorship-->
# 💜 Sponsor our work
Support our work by [becoming a backer](https://link.mijick.com/buymeacoffee).

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xEF",
"green" : "0xEF",
"red" : "0xF1"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "0.500",
"blue" : "0x05",
"green" : "0x05",
"red" : "0x05"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "0.800",
"blue" : "0x05",
"green" : "0x05",
"red" : "0x05"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x05",
"green" : "0x05",
"red" : "0x05"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x3A",
"green" : "0x36",
"red" : "0xBC"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x20",
"green" : "0x20",
"red" : "0x20"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x40",
"green" : "0xCA",
"red" : "0xF3"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xDF",
"green" : "0x90",
"red" : "0x8A"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xEF",
"green" : "0xEF",
"red" : "0xF1"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xC8",
"green" : "0xC6",
"red" : "0xC6"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xAB",
"green" : "0xA8",
"red" : "0xA8"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "mijick-icon-cancel.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 659 B

View File

@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "mijick-icon-change-camera.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "mijick-icon-check.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 634 B

View File

@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "mijick-icon-crosshair.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

View File

@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "mijick-icon-flash-auto.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "mijick-icon-flash-off.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 913 B

View File

@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "mijick-icon-flash-on.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 745 B

View File

@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "mijick-icon-flip-off.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "mijick-icon-flip-on.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 914 B

View File

@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "mijick-icon-grid-off.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 555 B

View File

@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "mijick-icon-grid-on.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 521 B

View File

@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "mijick-icon-light.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 692 B

View File

@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "mijick-icon-photo.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 869 B

View File

@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "mijick-icon-video.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 884 B

View File

@ -0,0 +1,46 @@
//
// AVCaptureVideoOrientation++.swift of MijickCamera
//
// Created by Tomasz Kurylik. Sending from Kraków!
// - Mail: tomasz.kurylik@mijick.com
// - GitHub: https://github.com/FulcrumOne
// - Medium: https://medium.com/@mijick
//
// Copyright ©2024 Mijick. All rights reserved.
import SwiftUI
import AVKit
// MARK: To Angle
extension AVCaptureVideoOrientation {
func getAngle() -> Angle { switch self {
case .portrait: .degrees(0)
case .landscapeLeft: .degrees(-90)
case .landscapeRight: .degrees(90)
case .portraitUpsideDown: .degrees(180)
default: .degrees(0)
}}
}
// MARK: To UIImageOrientation
extension AVCaptureVideoOrientation {
func toImageOrientation() -> UIImage.Orientation { switch self {
case .portrait: .downMirrored
case .landscapeLeft: .leftMirrored
case .landscapeRight: .rightMirrored
case .portraitUpsideDown: .upMirrored
default: .up
}}
}
// MARK: To UIDeviceOrientation
extension AVCaptureVideoOrientation {
func toDeviceOrientation() -> UIDeviceOrientation { switch self {
case .portrait: .portrait
case .portraitUpsideDown: .portraitUpsideDown
case .landscapeLeft: .landscapeLeft
case .landscapeRight: .landscapeRight
default: .portrait
}}
}

View File

@ -0,0 +1,20 @@
//
// AVVideoComposition++.swift of MijickCamera
//
// Created by Tomasz Kurylik. Sending from Kraków!
// - Mail: tomasz.kurylik@mijick.com
// - GitHub: https://github.com/FulcrumOne
// - Medium: https://medium.com/@mijick
//
// Copyright ©2024 Mijick. All rights reserved.
import AVKit
// MARK: Apply Filters
extension AVVideoComposition {
static func applyFilters(to asset: AVAsset, applyFiltersAction: @Sendable @escaping (AVAsynchronousCIImageFilteringRequest) -> ()) async throws -> AVVideoComposition {
if #available(iOS 16.0, *) { return try await AVVideoComposition.videoComposition(with: asset, applyingCIFiltersWithHandler: applyFiltersAction) }
return AVVideoComposition(asset: asset, applyingCIFiltersWithHandler: applyFiltersAction)
}
}

View File

@ -0,0 +1,20 @@
//
// Animation++.swift of MijickCamera
//
// Created by Tomasz Kurylik. Sending from Kraków!
// - Mail: tomasz.kurylik@mijick.com
// - GitHub: https://github.com/FulcrumOne
// - Medium: https://medium.com/@mijick
//
// Copyright ©2024 Mijick. All rights reserved.
import SwiftUI
// MARK: Custom Animation
extension Animation {
static var mSpring: Animation { .spring(duration: duration, bounce: 0, blendDuration: 0) }
}
extension Animation {
static var duration: CGFloat { 0.3 }
}

View File

@ -0,0 +1,14 @@
//
// CIFilter++.swift of MijickCamera
//
// Created by Tomasz Kurylik. Sending from Kraków!
// - Mail: tomasz.kurylik@mijick.com
// - GitHub: https://github.com/FulcrumOne
// - Medium: https://medium.com/@mijick
//
// Copyright ©2024 Mijick. All rights reserved.
import AVKit
extension CIFilter: @unchecked @retroactive Sendable {}

View File

@ -0,0 +1,24 @@
//
// CIImage++.swift of MijickCamera
//
// Created by Tomasz Kurylik. Sending from Kraków!
// - Mail: tomasz.kurylik@mijick.com
// - GitHub: https://github.com/FulcrumOne
// - Medium: https://medium.com/@mijick
//
// Copyright ©2024 Mijick. All rights reserved.
import SwiftUI
// MARK: Applying Filters
extension CIImage {
func applyingFilters(_ filters: [CIFilter]) -> CIImage {
var ciImage = self
filters.forEach {
$0.setValue(ciImage, forKey: kCIInputImageKey)
ciImage = $0.outputImage ?? ciImage
}
return ciImage
}
}

View File

@ -0,0 +1,21 @@
//
// CameraUtilities++.swift of MijickCamera
//
// Created by Tomasz Kurylik. Sending from Kraków!
// - Mail: tomasz.kurylik@mijick.com
// - GitHub: https://github.com/FulcrumOne
// - Medium: https://medium.com/@mijick
//
// Copyright ©2024 Mijick. All rights reserved.
import AVKit
// MARK: To Device Flash Mode
extension CameraFlashMode {
func toDeviceFlashMode() -> AVCaptureDevice.FlashMode { switch self {
case .off: .off
case .on: .on
case .auto: .auto
}}
}

View File

@ -0,0 +1,22 @@
//
// CaseIterable++.swift of MijickCamera
//
// Created by Tomasz Kurylik. Sending from Kraków!
// - Mail: tomasz.kurylik@mijick.com
// - GitHub: https://github.com/FulcrumOne
// - Medium: https://medium.com/@mijick
//
// Copyright ©2024 Mijick. All rights reserved.
import Foundation
// MARK: Next
extension CaseIterable where Self: Equatable {
func next() -> Self {
guard let index = Self.allCases.firstIndex(of: self) else { return self }
let nextIndex = Self.allCases.index(after: index)
return Self.allCases[nextIndex == Self.allCases.endIndex ? Self.allCases.startIndex : nextIndex]
}
}

View File

@ -0,0 +1,35 @@
//
// FileManager++.swift of MijickCamera
//
// Created by Tomasz Kurylik. Sending from Kraków!
// - Mail: tomasz.kurylik@mijick.com
// - GitHub: https://github.com/FulcrumOne
// - Medium: https://medium.com/@mijick
//
// Copyright ©2024 Mijick. All rights reserved.
import SwiftUI
// MARK: Prepare Place for Video Output
extension FileManager {
static func prepareURLForVideoOutput() -> URL? {
guard let fileUrl = getFileUrl() else { return nil }
clearPlaceIfTaken(fileUrl)
return fileUrl
}
}
private extension FileManager {
static func getFileUrl() -> URL? {
FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
.first?
.appendingPathComponent(videoPath)
}
static func clearPlaceIfTaken(_ url: URL) {
try? FileManager.default.removeItem(at: url)
}
}
private extension FileManager {
static var videoPath: String { "mijick-camera-video-output.mp4" }
}

View File

@ -0,0 +1,19 @@
//
// Task++.swift of MijickCamera
//
// Created by Tomasz Kurylik. Sending from Kraków!
// - Mail: tomasz.kurylik@mijick.com
// - GitHub: https://github.com/FulcrumOne
// - Medium: https://medium.com/@mijick
//
// Copyright ©2024 Mijick. All rights reserved.
import Foundation
// MARK: Sleep
extension Task where Success == Never, Failure == Never {
static func sleep(seconds: CGFloat) async {
try! await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
}
}

View File

@ -0,0 +1,26 @@
//
// UIImage.Orientation++.swift of MijickCamera
//
// Created by Tomasz Kurylik. Sending from Kraków!
// - Mail: tomasz.kurylik@mijick.com
// - GitHub: https://github.com/FulcrumOne
// - Medium: https://medium.com/@mijick
//
// Copyright ©2024 Mijick. All rights reserved.
import SwiftUI
// MARK: From CGImagePropertyOrientation
extension UIImage.Orientation {
init(_ orientation: CGImagePropertyOrientation) { switch orientation {
case .down: self = .down
case .downMirrored: self = .downMirrored
case .left: self = .left
case .leftMirrored: self = .leftMirrored
case .right: self = .right
case .rightMirrored: self = .rightMirrored
case .up: self = .up
case .upMirrored: self = .upMirrored
}}
}

View File

@ -0,0 +1,43 @@
//
// UIView++.swift of MijickCamera
//
// Created by Tomasz Kurylik. Sending from Kraków!
// - Mail: tomasz.kurylik@mijick.com
// - GitHub: https://github.com/FulcrumOne
// - Medium: https://medium.com/@mijick
//
// Copyright ©2024 Mijick. All rights reserved.
import SwiftUI
// MARK: Add to Parent
extension UIView {
func addToParent(_ view: UIView) {
view.addSubview(self)
translatesAutoresizingMaskIntoConstraints = false
leftAnchor.constraint(equalTo: view.leftAnchor, constant: 0).isActive = true
rightAnchor.constraint(equalTo: view.rightAnchor, constant: 0).isActive = true
topAnchor.constraint(equalTo: view.topAnchor, constant: 0).isActive = true
bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: 0).isActive = true
}
}
// MARK: Apply Blur Effect
extension UIView {
func applyBlurEffect(style: UIBlurEffect.Style) {
let blurEffectView = UIVisualEffectView()
blurEffectView.frame = bounds
blurEffectView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
blurEffectView.effect = UIBlurEffect(style: style)
addSubview(blurEffectView)
}
}
// MARK: Tags
extension Int {
static var blurViewTag: Int { 2137 }
static var focusIndicatorTag: Int { 29 }
}

View File

@ -0,0 +1,17 @@
//
// View++.swift of MijickCamera
//
// Created by Tomasz Kurylik. Sending from Kraków!
// - Mail: tomasz.kurylik@mijick.com
// - GitHub: https://github.com/FulcrumOne
// - Medium: https://medium.com/@mijick
//
// Copyright ©2024 Mijick. All rights reserved.
import SwiftUI
// MARK: Erased
extension View {
func erased() -> AnyView { .init(self) }
}

View File

@ -0,0 +1,40 @@
//
// CameraManager+Attributes.swift of MijickCamera
//
// Created by Tomasz Kurylik. Sending from Kraków!
// - Mail: tomasz.kurylik@mijick.com
// - GitHub: https://github.com/FulcrumOne
// - Medium: https://medium.com/@mijick
//
// Copyright ©2024 Mijick. All rights reserved.
import AVKit
import UIKit
struct CameraManagerAttributes {
var capturedMedia: MCameraMedia? = nil
var error: MCameraError? = nil
var outputType: CameraOutputType = .photo
var cameraPosition: CameraPosition = .back
var isAudioSourceAvailable: Bool = true
var zoomFactor: CGFloat = 1.0
var flashMode: CameraFlashMode = .off
var lightMode: CameraLightMode = .off
var resolution: AVCaptureSession.Preset = .hd1920x1080
var frameRate: Int32 = 30
var cameraExposure: CameraExposure = .init()
var hdrMode: CameraHDRMode = .auto
var cameraFilters: [CIFilter] = []
var mirrorOutput: Bool = false
var isGridVisible: Bool = true
/// Color for screen flash on front camera (nil = use default white)
var screenFlashColor: UIColor? = nil
var deviceOrientation: AVCaptureVideoOrientation = .portrait
var frameOrientation: CGImagePropertyOrientation = .right
var orientationLocked: Bool = false
var userBlockedScreenRotation: Bool = false
}

View File

@ -0,0 +1,108 @@
//
// CameraManager+MotionManager.swift of MijickCamera
//
// Created by Tomasz Kurylik. Sending from Kraków!
// - Mail: tomasz.kurylik@mijick.com
// - GitHub: https://github.com/FulcrumOne
// - Medium: https://medium.com/@mijick
//
// Copyright ©2024 Mijick. All rights reserved.
import CoreMotion
import AVKit
@MainActor class CameraManagerMotionManager {
private(set) var parent: CameraManager!
private(set) var manager: CMMotionManager = .init()
}
// MARK: Setup
extension CameraManagerMotionManager {
func setup(parent: CameraManager) {
self.parent = parent
manager.accelerometerUpdateInterval = 0.05
manager.startAccelerometerUpdates(to: .current ?? .init(), withHandler: handleAccelerometerUpdates)
}
}
private extension CameraManagerMotionManager {
func handleAccelerometerUpdates(_ data: CMAccelerometerData?, _ error: Error?) {
guard let data, error == nil else { return }
let newDeviceOrientation = getDeviceOrientation(data.acceleration)
updateDeviceOrientation(newDeviceOrientation)
updateUserBlockedScreenRotation()
updateFrameOrientation()
redrawGrid()
}
}
private extension CameraManagerMotionManager {
func getDeviceOrientation(_ acceleration: CMAcceleration) -> AVCaptureVideoOrientation { switch acceleration {
case let acceleration where acceleration.x >= 0.75: .landscapeLeft
case let acceleration where acceleration.x <= -0.75: .landscapeRight
case let acceleration where acceleration.y <= -0.75: .portrait
case let acceleration where acceleration.y >= 0.75: .portraitUpsideDown
default: parent.attributes.deviceOrientation
}}
func updateDeviceOrientation(_ newDeviceOrientation: AVCaptureVideoOrientation) { if newDeviceOrientation != parent.attributes.deviceOrientation {
parent.attributes.deviceOrientation = newDeviceOrientation
}}
func updateUserBlockedScreenRotation() {
let newUserBlockedScreenRotation = getNewUserBlockedScreenRotation()
if newUserBlockedScreenRotation != parent.attributes.userBlockedScreenRotation { parent.attributes.userBlockedScreenRotation = newUserBlockedScreenRotation }
}
func updateFrameOrientation() { if UIDevice.current.orientation != .portraitUpsideDown {
let newFrameOrientation = getNewFrameOrientation(parent.attributes.orientationLocked ? .portrait : UIDevice.current.orientation)
updateFrameOrientation(newFrameOrientation)
}}
func redrawGrid() { if !parent.attributes.orientationLocked {
parent.cameraGridView.draw(.zero)
}}
}
private extension CameraManagerMotionManager {
func getNewUserBlockedScreenRotation() -> Bool { switch parent.attributes.deviceOrientation.rawValue == UIDevice.current.orientation.rawValue {
case true: false
case false: !parent.attributes.orientationLocked
}}
func getNewFrameOrientation(_ orientation: UIDeviceOrientation) -> CGImagePropertyOrientation { switch parent.attributes.cameraPosition {
case .back: getNewFrameOrientationForBackCamera(orientation)
case .front: getNewFrameOrientationForFrontCamera(orientation)
}}
func updateFrameOrientation(_ newFrameOrientation: CGImagePropertyOrientation) { if newFrameOrientation != parent.attributes.frameOrientation {
let shouldAnimate = shouldAnimateFrameOrientationChange(newFrameOrientation)
updateFrameOrientation(withAnimation: shouldAnimate, newFrameOrientation: newFrameOrientation)
}}
}
private extension CameraManagerMotionManager {
func getNewFrameOrientationForBackCamera(_ orientation: UIDeviceOrientation) -> CGImagePropertyOrientation { switch orientation {
case .portrait: parent.attributes.mirrorOutput ? .leftMirrored : .right
case .landscapeLeft: parent.attributes.mirrorOutput ? .upMirrored : .up
case .landscapeRight: parent.attributes.mirrorOutput ? .downMirrored : .down
default: parent.attributes.mirrorOutput ? .leftMirrored : .right
}}
func getNewFrameOrientationForFrontCamera(_ orientation: UIDeviceOrientation) -> CGImagePropertyOrientation { switch orientation {
case .portrait: parent.attributes.mirrorOutput ? .right : .leftMirrored
case .landscapeLeft: parent.attributes.mirrorOutput ? .down : .downMirrored
case .landscapeRight: parent.attributes.mirrorOutput ? .up : .upMirrored
default: parent.attributes.mirrorOutput ? .right : .leftMirrored
}}
func shouldAnimateFrameOrientationChange(_ newFrameOrientation: CGImagePropertyOrientation) -> Bool {
let backCameraOrientations: [CGImagePropertyOrientation] = [.left, .right, .up, .down],
frontCameraOrientations: [CGImagePropertyOrientation] = [.leftMirrored, .rightMirrored, .upMirrored, .downMirrored]
return (backCameraOrientations.contains(newFrameOrientation) && backCameraOrientations.contains(parent.attributes.frameOrientation)) ||
(frontCameraOrientations.contains(parent.attributes.frameOrientation) && frontCameraOrientations.contains(newFrameOrientation))
}
func updateFrameOrientation(withAnimation shouldAnimate: Bool, newFrameOrientation: CGImagePropertyOrientation) { Task {
await parent.cameraMetalView.beginCameraOrientationAnimation(if: shouldAnimate)
parent.attributes.frameOrientation = newFrameOrientation
parent.cameraMetalView.finishCameraOrientationAnimation(if: shouldAnimate)
}}
}
// MARK: Reset
extension CameraManagerMotionManager {
func reset() {
manager.stopAccelerometerUpdates()
}
}

View File

@ -0,0 +1,37 @@
//
// CameraManager+NotificationCenter.swift of MijickCamera
//
// Created by Tomasz Kurylik. Sending from Kraków!
// - Mail: tomasz.kurylik@mijick.com
// - GitHub: https://github.com/FulcrumOne
// - Medium: https://medium.com/@mijick
//
// Copyright ©2024 Mijick. All rights reserved.
import Foundation
@MainActor class CameraManagerNotificationCenter {
private(set) var parent: CameraManager!
}
// MARK: Setup
extension CameraManagerNotificationCenter {
func setup(parent: CameraManager) {
self.parent = parent
NotificationCenter.default.addObserver(self, selector: #selector(handleSessionWasInterrupted), name: .AVCaptureSessionWasInterrupted, object: parent.captureSession)
}
}
private extension CameraManagerNotificationCenter {
@objc func handleSessionWasInterrupted() {
parent.attributes.lightMode = .off
parent.videoOutput.reset()
}
}
// MARK: Reset
extension CameraManagerNotificationCenter {
func reset() {
NotificationCenter.default.removeObserver(self, name: .AVCaptureSessionWasInterrupted, object: parent?.captureSession)
}
}

View File

@ -0,0 +1,46 @@
//
// CameraManager+PermissionsManager.swift of MijickCamera
//
// Created by Tomasz Kurylik. Sending from Kraków!
// - Mail: tomasz.kurylik@mijick.com
// - GitHub: https://github.com/FulcrumOne
// - Medium: https://medium.com/@mijick
//
// Copyright ©2024 Mijick. All rights reserved.
import AVKit
@MainActor class CameraManagerPermissionsManager {}
// MARK: Request Access
extension CameraManagerPermissionsManager {
func requestAccess(parent: CameraManager) async throws(MCameraError) {
do {
try await getAuthorizationStatus(for: .video)
if parent.attributes.isAudioSourceAvailable { try await getAuthorizationStatus(for: .audio) }
}
catch {
parent.attributes.error = error
throw error
}
}
}
private extension CameraManagerPermissionsManager {
func getAuthorizationStatus(for mediaType: AVMediaType) async throws(MCameraError) { switch AVCaptureDevice.authorizationStatus(for: mediaType) {
case .denied, .restricted: throw getPermissionsError(mediaType)
case .notDetermined: try await requestAccess(for: mediaType)
default: return
}}
}
private extension CameraManagerPermissionsManager {
func requestAccess(for mediaType: AVMediaType) async throws(MCameraError) {
let isGranted = await AVCaptureDevice.requestAccess(for: mediaType)
if !isGranted { throw getPermissionsError(mediaType) }
}
func getPermissionsError(_ mediaType: AVMediaType) -> MCameraError { switch mediaType {
case .audio: .microphonePermissionsNotGranted
case .video: .cameraPermissionsNotGranted
default: fatalError()
}}
}

View File

@ -0,0 +1,127 @@
//
// CameraManager+PhotoOutput.swift of MijickCamera
//
// Created by Tomasz Kurylik. Sending from Kraków!
// - Mail: tomasz.kurylik@mijick.com
// - GitHub: https://github.com/FulcrumOne
// - Medium: https://medium.com/@mijick
//
// Copyright ©2024 Mijick. All rights reserved.
import AVKit
@MainActor class CameraManagerPhotoOutput: NSObject {
private(set) var parent: CameraManager!
private(set) var output: AVCapturePhotoOutput = .init()
}
// MARK: Setup
extension CameraManagerPhotoOutput {
func setup(parent: CameraManager) throws(MCameraError) {
self.parent = parent
try self.parent.captureSession.add(output: output)
}
}
// MARK: - CAPTURE PHOTO
// MARK: Capture
extension CameraManagerPhotoOutput {
func capture() {
guard let parent else {
print("CameraManagerPhotoOutput: parent is nil, cannot capture")
return
}
configureOutput()
// Check if we should disable iOS Retina Flash (when using custom screen flash from SwiftUI layer)
let disableBuiltInFlash = parent.attributes.screenFlashColor != nil &&
parent.attributes.cameraPosition == .front &&
parent.attributes.flashMode != .off
let settings = getPhotoOutputSettings(disableFlash: disableBuiltInFlash)
output.capturePhoto(with: settings, delegate: self)
parent.cameraMetalView.performImageCaptureAnimation()
}
}
private extension CameraManagerPhotoOutput {
func getPhotoOutputSettings(disableFlash: Bool) -> AVCapturePhotoSettings {
let settings = AVCapturePhotoSettings()
// When using custom screen flash for front camera, disable iOS's built-in flash
// to prevent double-flash (our custom flash + iOS Retina Flash)
if disableFlash {
settings.flashMode = .off
} else {
// For back camera, use the requested flash mode if supported
let desiredFlashMode = parent.attributes.flashMode.toDeviceFlashMode()
if output.supportedFlashModes.contains(desiredFlashMode) {
settings.flashMode = desiredFlashMode
} else {
settings.flashMode = .off
}
}
return settings
}
func configureOutput() {
guard let connection = output.connection(with: .video), connection.isVideoMirroringSupported else { return }
connection.isVideoMirrored = parent.attributes.mirrorOutput ? parent.attributes.cameraPosition != .front : parent.attributes.cameraPosition == .front
connection.videoOrientation = parent.attributes.deviceOrientation
}
}
// MARK: Receive Data
extension CameraManagerPhotoOutput: @preconcurrency AVCapturePhotoCaptureDelegate {
func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: (any Error)?) {
guard let imageData = photo.fileDataRepresentation(),
let ciImage = CIImage(data: imageData)
else { return }
let capturedCIImage = prepareCIImage(ciImage, parent.attributes.cameraFilters)
let capturedCGImage = prepareCGImage(capturedCIImage)
let capturedUIImage = prepareUIImage(capturedCGImage)
let capturedMedia = MCameraMedia(data: capturedUIImage)
parent.setCapturedMedia(capturedMedia)
}
}
private extension CameraManagerPhotoOutput {
func prepareCIImage(_ ciImage: CIImage, _ filters: [CIFilter]) -> CIImage {
ciImage.applyingFilters(filters)
}
func prepareCGImage(_ ciImage: CIImage) -> CGImage? {
CIContext().createCGImage(ciImage, from: ciImage.extent)
}
func prepareUIImage(_ cgImage: CGImage?) -> UIImage? {
guard let cgImage else { return nil }
let frameOrientation = getFixedFrameOrientation()
let orientation = UIImage.Orientation(frameOrientation)
let uiImage = UIImage(cgImage: cgImage, scale: 1.0, orientation: orientation)
return uiImage
}
}
private extension CameraManagerPhotoOutput {
func getFixedFrameOrientation() -> CGImagePropertyOrientation {
guard UIDevice.current.orientation != parent.attributes.deviceOrientation.toDeviceOrientation() else { return parent.attributes.frameOrientation }
return switch (parent.attributes.deviceOrientation, parent.attributes.cameraPosition) {
case (.portrait, .front): .left
case (.portrait, .back): .right
case (.landscapeLeft, .back): .down
case (.landscapeRight, .back): .up
case (.landscapeLeft, .front) where parent.attributes.mirrorOutput: .up
case (.landscapeLeft, .front): .upMirrored
case (.landscapeRight, .front) where parent.attributes.mirrorOutput: .down
case (.landscapeRight, .front): .downMirrored
default: .right
}
}
}

View File

@ -0,0 +1,158 @@
//
// CameraManager+VideoOutput.swift of MijickCamera
//
// Created by Tomasz Kurylik. Sending from Kraków!
// - Mail: tomasz.kurylik@mijick.com
// - GitHub: https://github.com/FulcrumOne
// - Medium: https://medium.com/@mijick
//
// Copyright ©2024 Mijick. All rights reserved.
@preconcurrency import AVKit
import SwiftUI
import MijickTimer
@MainActor class CameraManagerVideoOutput: NSObject {
private(set) var parent: CameraManager!
private(set) var output: AVCaptureMovieFileOutput = .init()
private(set) var timer: MTimer = .init(.camera)
private(set) var recordingTime: MTime = .zero
private(set) var firstRecordedFrame: UIImage?
}
// MARK: Setup
extension CameraManagerVideoOutput {
func setup(parent: CameraManager) throws(MCameraError) {
self.parent = parent
try parent.captureSession.add(output: output)
}
}
// MARK: Reset
extension CameraManagerVideoOutput {
func reset() {
timer.reset()
}
}
// MARK: - CAPTURE VIDEO
// MARK: Toggle
extension CameraManagerVideoOutput {
func toggleRecording() { switch output.isRecording {
case true: stopRecording()
case false: startRecording()
}}
}
// MARK: Start Recording
private extension CameraManagerVideoOutput {
func startRecording() {
guard let url = prepareUrlForVideoRecording() else { return }
configureOutput()
storeLastFrame()
output.startRecording(to: url, recordingDelegate: self)
startRecordingTimer()
parent.objectWillChange.send()
}
}
private extension CameraManagerVideoOutput {
func prepareUrlForVideoRecording() -> URL? {
FileManager.prepareURLForVideoOutput()
}
func configureOutput() {
guard let connection = output.connection(with: .video), connection.isVideoMirroringSupported else { return }
connection.isVideoMirrored = parent.attributes.mirrorOutput ? parent.attributes.cameraPosition != .front : parent.attributes.cameraPosition == .front
connection.videoOrientation = parent.attributes.deviceOrientation
}
func storeLastFrame() {
guard let texture = parent.cameraMetalView.currentDrawable?.texture,
let ciImage = CIImage(mtlTexture: texture, options: nil),
let cgImage = parent.cameraMetalView.ciContext.createCGImage(ciImage, from: ciImage.extent)
else { return }
firstRecordedFrame = UIImage(cgImage: cgImage, scale: 1.0, orientation: parent.attributes.deviceOrientation.toImageOrientation())
}
func startRecordingTimer() { try? timer
.publish(every: 1) { [self] in
recordingTime = $0
parent.objectWillChange.send()
}
.start()
}
}
// MARK: Stop Recording
private extension CameraManagerVideoOutput {
func stopRecording() {
presentLastFrame()
output.stopRecording()
timer.reset()
}
}
private extension CameraManagerVideoOutput {
func presentLastFrame() {
let firstRecordedFrame = MCameraMedia(data: firstRecordedFrame)
parent.setCapturedMedia(firstRecordedFrame)
}
}
// MARK: Receive Data
extension CameraManagerVideoOutput: @preconcurrency AVCaptureFileOutputRecordingDelegate {
func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: (any Error)?) { Task {
let videoURL = try await prepareVideo(outputFileURL: outputFileURL, cameraFilters: parent.attributes.cameraFilters)
let capturedVideo = MCameraMedia(data: videoURL)
await Task.sleep(seconds: Animation.duration)
parent.setCapturedMedia(capturedVideo)
}}
}
private extension CameraManagerVideoOutput {
func prepareVideo(outputFileURL: URL, cameraFilters: [CIFilter]) async throws -> URL {
if cameraFilters.isEmpty { return outputFileURL }
let asset = AVAsset(url: outputFileURL)
let videoComposition = try await AVVideoComposition.applyFilters(to: asset) { self.applyFiltersToVideo($0, cameraFilters) }
let fileUrl = FileManager.prepareURLForVideoOutput()
let exportSession = prepareAssetExportSession(asset, fileUrl, videoComposition)
try await exportVideo(exportSession, fileUrl)
return fileUrl ?? outputFileURL
}
}
private extension CameraManagerVideoOutput {
nonisolated func applyFiltersToVideo(_ request: AVAsynchronousCIImageFilteringRequest, _ filters: [CIFilter]) {
let videoFrame = prepareVideoFrame(request, filters)
request.finish(with: videoFrame, context: nil)
}
nonisolated func exportVideo(_ exportSession: AVAssetExportSession?, _ fileUrl: URL?) async throws { if let fileUrl {
if #available(iOS 18, *) { try await exportSession?.export(to: fileUrl, as: .mov) }
else { await exportSession?.export() }
}}
}
private extension CameraManagerVideoOutput {
nonisolated func prepareVideoFrame(_ request: AVAsynchronousCIImageFilteringRequest, _ filters: [CIFilter]) -> CIImage { request
.sourceImage
.clampedToExtent()
.applyingFilters(filters)
}
nonisolated func prepareAssetExportSession(_ asset: AVAsset, _ fileUrl: URL?, _ composition: AVVideoComposition?) -> AVAssetExportSession? {
let export = AVAssetExportSession(asset: asset, presetName: AVAssetExportPreset1920x1080)
export?.outputFileType = .mov
export?.outputURL = fileUrl
export?.videoComposition = composition
return export
}
}
// MARK: - HELPERS
fileprivate extension MTimerID {
static let camera: MTimerID = .init(rawValue: "mijick-camera")
}

View File

@ -0,0 +1,438 @@
//
// CameraManager.swift of MijickCamera
//
// Created by Tomasz Kurylik. Sending from Kraków!
// - Mail: tomasz.kurylik@mijick.com
// - GitHub: https://github.com/FulcrumOne
// - Medium: https://medium.com/@mijick
//
// Copyright ©2024 Mijick. All rights reserved.
import SwiftUI
import AVKit
@MainActor public class CameraManager: NSObject, ObservableObject {
@Published var attributes: CameraManagerAttributes = .init()
// MARK: Input
private(set) var captureSession: any CaptureSession
private(set) var frontCameraInput: (any CaptureDeviceInput)?
private(set) var backCameraInput: (any CaptureDeviceInput)?
// MARK: Output
private(set) var photoOutput: CameraManagerPhotoOutput = .init()
private(set) var videoOutput: CameraManagerVideoOutput = .init()
// MARK: UI Elements
private(set) var cameraView: UIView!
private(set) var cameraLayer: AVCaptureVideoPreviewLayer = .init()
private(set) var cameraMetalView: CameraMetalView = .init()
private(set) var cameraGridView: CameraGridView = .init()
// MARK: Others
private(set) var permissionsManager: CameraManagerPermissionsManager = .init()
private(set) var motionManager: CameraManagerMotionManager = .init()
private(set) var notificationCenterManager: CameraManagerNotificationCenter = .init()
// MARK: Initializer
init<CS: CaptureSession, CDI: CaptureDeviceInput>(captureSession: CS, captureDeviceInputType: CDI.Type) {
self.captureSession = captureSession
self.frontCameraInput = CDI.get(mediaType: .video, position: .front)
self.backCameraInput = CDI.get(mediaType: .video, position: .back)
}
}
// MARK: Initialize
extension CameraManager {
func initialize(in view: UIView) {
cameraView = view
}
}
// MARK: Setup
extension CameraManager {
func setup() async throws(MCameraError) {
try await permissionsManager.requestAccess(parent: self)
setupCameraLayer()
try setupDeviceInputs()
try setupDeviceOutput()
try setupFrameRecorder()
notificationCenterManager.setup(parent: self)
motionManager.setup(parent: self)
try cameraMetalView.setup(parent: self)
cameraGridView.setup(parent: self)
startSession()
}
}
private extension CameraManager {
func setupCameraLayer() {
captureSession.sessionPreset = attributes.resolution
cameraLayer.session = captureSession as? AVCaptureSession
cameraLayer.videoGravity = .resizeAspectFill
cameraLayer.isHidden = true
cameraView.layer.addSublayer(cameraLayer)
}
func setupDeviceInputs() throws(MCameraError) {
try captureSession.add(input: getCameraInput())
if let audioInput = getAudioInput() { try captureSession.add(input: audioInput) }
}
func setupDeviceOutput() throws(MCameraError) {
try photoOutput.setup(parent: self)
try videoOutput.setup(parent: self)
}
func setupFrameRecorder() throws(MCameraError) {
let captureVideoOutput = AVCaptureVideoDataOutput()
captureVideoOutput.setSampleBufferDelegate(cameraMetalView, queue: .main)
try captureSession.add(output: captureVideoOutput)
}
func startSession() { Task {
guard let device = getCameraInput()?.device else { return }
try await startCaptureSession()
try setupDevice(device)
resetAttributes(device: device)
cameraMetalView.performCameraEntranceAnimation()
}}
}
private extension CameraManager {
func getAudioInput() -> (any CaptureDeviceInput)? {
guard attributes.isAudioSourceAvailable,
let deviceInput = frontCameraInput ?? backCameraInput
else { return nil }
let captureDeviceInputType = type(of: deviceInput)
let audioInput = captureDeviceInputType.get(mediaType: .audio, position: .unspecified)
return audioInput
}
nonisolated func startCaptureSession() async throws {
await captureSession.startRunning()
}
func setupDevice(_ device: any CaptureDevice) throws {
try device.lockForConfiguration()
device.setExposureMode(attributes.cameraExposure.mode, duration: attributes.cameraExposure.duration, iso: attributes.cameraExposure.iso)
device.setExposureTargetBias(attributes.cameraExposure.targetBias)
device.setFrameRate(attributes.frameRate)
device.setZoomFactor(attributes.zoomFactor)
device.setLightMode(attributes.lightMode)
device.hdrMode = attributes.hdrMode
device.unlockForConfiguration()
}
}
// MARK: Cancel
extension CameraManager {
func cancel() {
captureSession = captureSession.stopRunningAndReturnNewInstance()
motionManager.reset()
videoOutput.reset()
notificationCenterManager.reset()
}
}
// MARK: - LIVE ACTIONS
// MARK: Capture Output
extension CameraManager {
func captureOutput() {
guard !isChanging else { return }
switch attributes.outputType {
case .photo: photoOutput.capture()
case .video: videoOutput.toggleRecording()
}
}
}
// MARK: Set Captured Media
public extension CameraManager {
func setCapturedMedia(_ capturedMedia: MCameraMedia?) { withAnimation(.mSpring) {
attributes.capturedMedia = capturedMedia
}}
var capturedMedia: MCameraMedia? {
attributes.capturedMedia
}
}
// MARK: Set Camera Output
extension CameraManager {
func setOutputType(_ outputType: CameraOutputType) {
guard outputType != attributes.outputType, !isChanging else { return }
attributes.outputType = outputType
}
}
// MARK: Set Camera Position
extension CameraManager {
func setCameraPosition(_ position: CameraPosition) async throws {
guard position != attributes.cameraPosition, !isChanging else { return }
await cameraMetalView.beginCameraFlipAnimation()
try changeCameraInput(position)
resetAttributesWhenChangingCamera(position)
await cameraMetalView.finishCameraFlipAnimation()
}
}
private extension CameraManager {
func changeCameraInput(_ position: CameraPosition) throws {
if let input = getCameraInput() { captureSession.remove(input: input) }
try captureSession.add(input: getCameraInput(position))
}
func resetAttributesWhenChangingCamera(_ position: CameraPosition) {
resetAttributes(device: getCameraInput(position)?.device)
attributes.cameraPosition = position
}
}
// MARK: Set Camera Zoom
extension CameraManager {
func setCameraZoomFactor(_ zoomFactor: CGFloat) throws {
guard let device = getCameraInput()?.device, zoomFactor != attributes.zoomFactor, !isChanging else { return }
try setDeviceZoomFactor(zoomFactor, device)
attributes.zoomFactor = device.videoZoomFactor
}
}
private extension CameraManager {
func setDeviceZoomFactor(_ zoomFactor: CGFloat, _ device: any CaptureDevice) throws {
try device.lockForConfiguration()
device.setZoomFactor(zoomFactor)
device.unlockForConfiguration()
}
}
// MARK: Set Camera Focus
extension CameraManager {
func setCameraFocus(at touchPoint: CGPoint) throws {
guard let device = getCameraInput()?.device, !isChanging else { return }
let focusPoint = convertTouchPointToFocusPoint(touchPoint)
try setDeviceCameraFocus(focusPoint, device)
cameraMetalView.performCameraFocusAnimation(touchPoint: touchPoint)
}
}
private extension CameraManager {
func convertTouchPointToFocusPoint(_ touchPoint: CGPoint) -> CGPoint { .init(
x: touchPoint.y / cameraView.frame.height,
y: 1 - touchPoint.x / cameraView.frame.width
)}
func setDeviceCameraFocus(_ focusPoint: CGPoint, _ device: any CaptureDevice) throws {
try device.lockForConfiguration()
device.setFocusPointOfInterest(focusPoint)
device.setExposurePointOfInterest(focusPoint)
device.unlockForConfiguration()
}
}
// MARK: Set Flash Mode
extension CameraManager {
func setFlashMode(_ flashMode: CameraFlashMode) {
guard let device = getCameraInput()?.device, device.hasFlash, flashMode != attributes.flashMode, !isChanging else { return }
attributes.flashMode = flashMode
}
}
// MARK: Set Screen Flash Color
extension CameraManager {
func setScreenFlashColor(_ color: UIColor?) {
attributes.screenFlashColor = color
}
}
// MARK: Set Light Mode
extension CameraManager {
func setLightMode(_ lightMode: CameraLightMode) throws {
guard let device = getCameraInput()?.device, device.hasTorch, lightMode != attributes.lightMode, !isChanging else { return }
try setDeviceLightMode(lightMode, device)
attributes.lightMode = device.lightMode
}
}
private extension CameraManager {
func setDeviceLightMode(_ lightMode: CameraLightMode, _ device: any CaptureDevice) throws {
try device.lockForConfiguration()
device.setLightMode(lightMode)
device.unlockForConfiguration()
}
}
// MARK: Set Mirror Output
extension CameraManager {
func setMirrorOutput(_ mirrorOutput: Bool) {
guard mirrorOutput != attributes.mirrorOutput, !isChanging else { return }
attributes.mirrorOutput = mirrorOutput
}
}
// MARK: Set Grid Visibility
extension CameraManager {
func setGridVisibility(_ isGridVisible: Bool) {
guard isGridVisible != attributes.isGridVisible, !isChanging else { return }
cameraGridView.setVisibility(isGridVisible)
}
}
// MARK: Set Camera Filters
extension CameraManager {
func setCameraFilters(_ cameraFilters: [CIFilter]) {
guard cameraFilters != attributes.cameraFilters, !isChanging else { return }
attributes.cameraFilters = cameraFilters
}
}
// MARK: Set Exposure Mode
extension CameraManager {
func setExposureMode(_ exposureMode: AVCaptureDevice.ExposureMode) throws {
guard let device = getCameraInput()?.device, exposureMode != attributes.cameraExposure.mode, !isChanging else { return }
try setDeviceExposureMode(exposureMode, device)
attributes.cameraExposure.mode = device.exposureMode
}
}
private extension CameraManager {
func setDeviceExposureMode(_ exposureMode: AVCaptureDevice.ExposureMode, _ device: any CaptureDevice) throws {
try device.lockForConfiguration()
device.setExposureMode(exposureMode, duration: attributes.cameraExposure.duration, iso: attributes.cameraExposure.iso)
device.unlockForConfiguration()
}
}
// MARK: Set Exposure Duration
extension CameraManager {
func setExposureDuration(_ exposureDuration: CMTime) throws {
guard let device = getCameraInput()?.device, exposureDuration != attributes.cameraExposure.duration, !isChanging else { return }
try setDeviceExposureDuration(exposureDuration, device)
attributes.cameraExposure.duration = device.exposureDuration
}
}
private extension CameraManager {
func setDeviceExposureDuration(_ exposureDuration: CMTime, _ device: any CaptureDevice) throws {
try device.lockForConfiguration()
device.setExposureMode(.custom, duration: exposureDuration, iso: attributes.cameraExposure.iso)
device.unlockForConfiguration()
}
}
// MARK: Set ISO
extension CameraManager {
func setISO(_ iso: Float) throws {
guard let device = getCameraInput()?.device, iso != attributes.cameraExposure.iso, !isChanging else { return }
try setDeviceISO(iso, device)
attributes.cameraExposure.iso = device.iso
}
}
private extension CameraManager {
func setDeviceISO(_ iso: Float, _ device: any CaptureDevice) throws {
try device.lockForConfiguration()
device.setExposureMode(.custom, duration: attributes.cameraExposure.duration, iso: iso)
device.unlockForConfiguration()
}
}
// MARK: Set Exposure Target Bias
extension CameraManager {
func setExposureTargetBias(_ exposureTargetBias: Float) throws {
guard let device = getCameraInput()?.device, exposureTargetBias != attributes.cameraExposure.targetBias, !isChanging else { return }
try setDeviceExposureTargetBias(exposureTargetBias, device)
attributes.cameraExposure.targetBias = device.exposureTargetBias
}
}
private extension CameraManager {
func setDeviceExposureTargetBias(_ exposureTargetBias: Float, _ device: any CaptureDevice) throws {
try device.lockForConfiguration()
device.setExposureTargetBias(exposureTargetBias)
device.unlockForConfiguration()
}
}
// MARK: Set HDR Mode
extension CameraManager {
func setHDRMode(_ hdrMode: CameraHDRMode) throws {
guard let device = getCameraInput()?.device, hdrMode != attributes.hdrMode, !isChanging else { return }
try setDeviceHDRMode(hdrMode, device)
attributes.hdrMode = hdrMode
}
}
private extension CameraManager {
func setDeviceHDRMode(_ hdrMode: CameraHDRMode, _ device: any CaptureDevice) throws {
try device.lockForConfiguration()
device.hdrMode = hdrMode
device.unlockForConfiguration()
}
}
// MARK: Set Resolution
extension CameraManager {
func setResolution(_ resolution: AVCaptureSession.Preset) {
guard resolution != attributes.resolution, resolution != attributes.resolution, !isChanging else { return }
captureSession.sessionPreset = resolution
attributes.resolution = resolution
}
}
// MARK: Set Frame Rate
extension CameraManager {
func setFrameRate(_ frameRate: Int32) throws {
guard let device = getCameraInput()?.device, frameRate != attributes.frameRate, !isChanging else { return }
try setDeviceFrameRate(frameRate, device)
attributes.frameRate = device.activeVideoMaxFrameDuration.timescale
}
}
private extension CameraManager {
func setDeviceFrameRate(_ frameRate: Int32, _ device: any CaptureDevice) throws {
try device.lockForConfiguration()
device.setFrameRate(frameRate)
device.unlockForConfiguration()
}
}
// MARK: - HELPERS
// MARK: Attributes
extension CameraManager {
var hasFlash: Bool { getCameraInput()?.device.hasFlash ?? false }
var hasLight: Bool { getCameraInput()?.device.hasTorch ?? false }
}
private extension CameraManager {
var isChanging: Bool { cameraMetalView.isAnimating }
}
// MARK: Methods
extension CameraManager {
func resetAttributes(device: (any CaptureDevice)?) {
guard let device else { return }
var newAttributes = attributes
newAttributes.cameraExposure.mode = device.exposureMode
newAttributes.cameraExposure.duration = device.exposureDuration
newAttributes.cameraExposure.iso = device.iso
newAttributes.cameraExposure.targetBias = device.exposureTargetBias
newAttributes.frameRate = device.activeVideoMaxFrameDuration.timescale
newAttributes.zoomFactor = device.videoZoomFactor
newAttributes.lightMode = device.lightMode
newAttributes.hdrMode = device.hdrMode
attributes = newAttributes
}
func getCameraInput(_ position: CameraPosition? = nil) -> (any CaptureDeviceInput)? { switch position ?? attributes.cameraPosition {
case .front: frontCameraInput
case .back: backCameraInput
}}
}

View File

@ -0,0 +1,26 @@
//
// CaptureDeviceInput+AVCaptureDeviceInput.swift of MijickCamera
//
// Created by Tomasz Kurylik. Sending from Kraków!
// - Mail: tomasz.kurylik@mijick.com
// - GitHub: https://github.com/FulcrumOne
// - Medium: https://medium.com/@mijick
//
// Copyright ©2024 Mijick. All rights reserved.
import AVKit
extension AVCaptureDeviceInput: CaptureDeviceInput {
static func get(mediaType: AVMediaType, position: AVCaptureDevice.Position?) -> Self? {
let device = { switch mediaType {
case .audio: AVCaptureDevice.default(for: .audio)
case .video where position == .front: AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front)
case .video where position == .back: AVCaptureDevice.default(for: .video)
default: fatalError()
}}()
guard let device, let deviceInput = try? Self(device: device) else { return nil }
return deviceInput
}
}

View File

@ -0,0 +1,26 @@
//
// CaptureDeviceInput+MockDeviceInput.swift of MijickCamera
//
// Created by Tomasz Kurylik. Sending from Kraków!
// - Mail: tomasz.kurylik@mijick.com
// - GitHub: https://github.com/FulcrumOne
// - Medium: https://medium.com/@mijick
//
// Copyright ©2024 Mijick. All rights reserved.
import AVKit
class MockDeviceInput: NSObject, CaptureDeviceInput { required override init() {}
var device: MockCaptureDevice = .init()
}
// MARK: Methods
extension MockDeviceInput {
static func get(mediaType: AVMediaType, position: AVCaptureDevice.Position?) -> Self? { .init() }
}
// MARK: Equatable
extension MockDeviceInput {
static func == (lhs: MockDeviceInput, rhs: MockDeviceInput) -> Bool { lhs.device.uniqueID == rhs.device.uniqueID }
}

View File

@ -0,0 +1,21 @@
//
// CaptureDeviceInput.swift of MijickCamera
//
// Created by Tomasz Kurylik. Sending from Kraków!
// - Mail: tomasz.kurylik@mijick.com
// - GitHub: https://github.com/FulcrumOne
// - Medium: https://medium.com/@mijick
//
// Copyright ©2024 Mijick. All rights reserved.
import AVKit
protocol CaptureDeviceInput: NSObject {
// MARK: Attributes
associatedtype CD: CaptureDevice
var device: CD { get }
// MARK: Methods
static func get(mediaType: AVMediaType, position: AVCaptureDevice.Position?) -> Self?
}

View File

@ -0,0 +1,41 @@
//
// CaptureDevice+AVCaptureDevice.swift of MijickCamera
//
// Created by Tomasz Kurylik. Sending from Kraków!
// - Mail: tomasz.kurylik@mijick.com
// - GitHub: https://github.com/FulcrumOne
// - Medium: https://medium.com/@mijick
//
// Copyright ©2024 Mijick. All rights reserved.
import AVKit
// MARK: Getters
extension AVCaptureDevice: CaptureDevice {
var minExposureDuration: CMTime { activeFormat.minExposureDuration }
var maxExposureDuration: CMTime { activeFormat.maxExposureDuration }
var minISO: Float { activeFormat.minISO }
var maxISO: Float { activeFormat.maxISO }
var minFrameRate: Float64? { activeFormat.videoSupportedFrameRateRanges.first?.minFrameRate }
var maxFrameRate: Float64? { activeFormat.videoSupportedFrameRateRanges.first?.maxFrameRate }
}
// MARK: Getters & Setters
extension AVCaptureDevice {
var lightMode: CameraLightMode {
get { torchMode == .off ? .off : .on }
set { torchMode = newValue == .off ? .off : .on }
}
var hdrMode: CameraHDRMode {
get {
if automaticallyAdjustsVideoHDREnabled { return .auto }
else if isVideoHDREnabled { return .on }
else { return .off }
}
set {
automaticallyAdjustsVideoHDREnabled = newValue == .auto
if newValue != .auto { isVideoHDREnabled = newValue == .on }
}
}
}

View File

@ -0,0 +1,62 @@
//
// CaptureDevice+MockCaptureDevice.swift of MijickCamera
//
// Created by Tomasz Kurylik. Sending from Kraków!
// - Mail: tomasz.kurylik@mijick.com
// - GitHub: https://github.com/FulcrumOne
// - Medium: https://medium.com/@mijick
//
// Copyright ©2024 Mijick. All rights reserved.
import AVKit
class MockCaptureDevice: NSObject, CaptureDevice {
// MARK: Getters
var uniqueID: String = UUID().uuidString
var exposureDuration: CMTime { _exposureDuration }
var exposureTargetBias: Float { _exposureTargetBias }
var iso: Float { _iso }
var minAvailableVideoZoomFactor: CGFloat { 1 }
var maxAvailableVideoZoomFactor: CGFloat { 3.876 }
var minExposureDuration: CMTime { .init(value: 1, timescale: 1000) }
var maxExposureDuration: CMTime { .init(value: 1, timescale: 5) }
var minISO: Float { 1 }
var maxISO: Float { 10 }
var minExposureTargetBias: Float { 0.1 }
var maxExposureTargetBias: Float { 199 }
var minFrameRate: Float64? { 15 }
var maxFrameRate: Float64? { 60 }
var hasFlash: Bool { true }
var hasTorch: Bool { true }
var isExposurePointOfInterestSupported: Bool { true }
var isFocusPointOfInterestSupported: Bool { true }
// MARK: Setters
var videoZoomFactor: CGFloat = 1
var focusMode: AVCaptureDevice.FocusMode = .autoFocus
var focusPointOfInterest: CGPoint = .zero
var exposurePointOfInterest: CGPoint = .zero
var lightMode: CameraLightMode = .off
var activeVideoMinFrameDuration: CMTime = .init()
var activeVideoMaxFrameDuration: CMTime = .init()
var exposureMode: AVCaptureDevice.ExposureMode = .continuousAutoExposure
var hdrMode: CameraHDRMode = .auto
// MARK: Methods
func lockForConfiguration() throws { return }
func unlockForConfiguration() { return }
func isExposureModeSupported(_ exposureMode: AVCaptureDevice.ExposureMode) -> Bool { true }
func setExposureModeCustom(duration: CMTime, iso: Float, completionHandler: ((CMTime) -> Void)?) {
_exposureDuration = duration
_iso = iso
}
func setExposureTargetBias(_ bias: Float, completionHandler handler: ((CMTime) -> ())?) {
_exposureTargetBias = bias
}
// MARK: Private Attributes
private var _exposureDuration: CMTime = .init()
private var _exposureTargetBias: Float = 0
private var _iso: Float = 0
}

View File

@ -0,0 +1,131 @@
//
// CaptureDevice.swift of MijickCamera
//
// Created by Tomasz Kurylik. Sending from Kraków!
// - Mail: tomasz.kurylik@mijick.com
// - GitHub: https://github.com/FulcrumOne
// - Medium: https://medium.com/@mijick
//
// Copyright ©2024 Mijick. All rights reserved.
import AVKit
protocol CaptureDevice: NSObject {
// MARK: Getters
var uniqueID: String { get }
var exposureDuration: CMTime { get }
var exposureTargetBias: Float { get }
var iso: Float { get }
var minAvailableVideoZoomFactor: CGFloat { get }
var maxAvailableVideoZoomFactor: CGFloat { get }
var minExposureDuration: CMTime { get }
var maxExposureDuration: CMTime { get }
var minISO: Float { get }
var maxISO: Float { get }
var minExposureTargetBias: Float { get }
var maxExposureTargetBias: Float { get }
var minFrameRate: Float64? { get }
var maxFrameRate: Float64? { get }
var hasFlash: Bool { get }
var hasTorch: Bool { get }
var isExposurePointOfInterestSupported: Bool { get }
var isFocusPointOfInterestSupported: Bool { get }
// MARK: Getters & Setters
var videoZoomFactor: CGFloat { get set }
var focusMode: AVCaptureDevice.FocusMode { get set }
var focusPointOfInterest: CGPoint { get set }
var exposurePointOfInterest: CGPoint { get set }
var lightMode: CameraLightMode { get set }
var activeVideoMinFrameDuration: CMTime { get set }
var activeVideoMaxFrameDuration: CMTime { get set }
var exposureMode: AVCaptureDevice.ExposureMode { get set }
var hdrMode: CameraHDRMode { get set }
// MARK: Methods
func lockForConfiguration() throws
func unlockForConfiguration()
func isExposureModeSupported(_ exposureMode: AVCaptureDevice.ExposureMode) -> Bool
func setExposureModeCustom(duration: CMTime, iso: Float, completionHandler: (@Sendable (CMTime) -> Void)?)
func setExposureTargetBias(_ bias: Float, completionHandler handler: (@Sendable (CMTime) -> ())?)
}
// MARK: - METHODS
// MARK: Set Zoom Factor
extension CaptureDevice {
func setZoomFactor(_ factor: CGFloat) {
let factor = max(min(factor, min(maxAvailableVideoZoomFactor, 5)), minAvailableVideoZoomFactor)
videoZoomFactor = factor
}
}
// MARK: Set Focus Point Of Interest
extension CaptureDevice {
func setFocusPointOfInterest(_ point: CGPoint) {
guard isFocusPointOfInterestSupported else { return }
focusPointOfInterest = point
focusMode = .autoFocus
}
}
// MARK: Set Exposure Point Of Interest
extension CaptureDevice {
func setExposurePointOfInterest(_ point: CGPoint) {
guard isExposurePointOfInterestSupported else { return }
exposurePointOfInterest = point
exposureMode = .autoExpose
}
}
// MARK: Set Light Mode
extension CaptureDevice {
func setLightMode(_ mode: CameraLightMode) {
guard hasTorch else { return }
lightMode = mode
}
}
// MARK: Set Frame Rate
extension CaptureDevice {
func setFrameRate(_ frameRate: Int32) {
guard let minFrameRate, let maxFrameRate else { return }
let frameRate = max(min(frameRate, Int32(maxFrameRate)), Int32(minFrameRate))
activeVideoMinFrameDuration = CMTime(value: 1, timescale: frameRate)
activeVideoMaxFrameDuration = CMTime(value: 1, timescale: frameRate)
}
}
// MARK: Set Exposure Mode
extension CaptureDevice {
func setExposureMode(_ mode: AVCaptureDevice.ExposureMode, duration: CMTime, iso: Float) {
guard isExposureModeSupported(mode) else { return }
exposureMode = mode
guard mode == .custom else { return }
let duration = max(min(duration, maxExposureDuration), minExposureDuration)
let iso = max(min(iso, maxISO), minISO)
setExposureModeCustom(duration: duration, iso: iso, completionHandler: nil)
}
}
// MARK: Set Exposure Target Bias
extension CaptureDevice {
func setExposureTargetBias(_ bias: Float) {
guard isExposureModeSupported(.custom) else { return }
let bias = max(min(bias, maxExposureTargetBias), minExposureTargetBias)
setExposureTargetBias(bias, completionHandler: nil)
}
}

View File

@ -0,0 +1,45 @@
//
// CaptureSession+AVCaptureSession.swift of MijickCamera
//
// Created by Tomasz Kurylik. Sending from Kraków!
// - Mail: tomasz.kurylik@mijick.com
// - GitHub: https://github.com/FulcrumOne
// - Medium: https://medium.com/@mijick
//
// Copyright ©2024 Mijick. All rights reserved.
import AVKit
extension AVCaptureSession: @unchecked @retroactive Sendable {}
extension AVCaptureSession: CaptureSession {
var deviceInputs: [any CaptureDeviceInput] { inputs as? [any CaptureDeviceInput] ?? [] }
}
// MARK: - METHODS
extension AVCaptureSession {
func stopRunningAndReturnNewInstance() -> any CaptureSession {
self.stopRunning()
return AVCaptureSession()
}
}
extension AVCaptureSession {
func add(input: (any CaptureDeviceInput)?) throws(MCameraError) {
guard let input = input as? AVCaptureDeviceInput else { throw .cannotSetupInput }
if canAddInput(input) { addInput(input) }
}
func remove(input: (any CaptureDeviceInput)?) {
guard let input = input as? AVCaptureDeviceInput else { return }
removeInput(input)
}
}
extension AVCaptureSession {
func add(output: AVCaptureOutput?) throws(MCameraError) {
guard let output else { throw .cannotSetupOutput }
if canAddOutput(output) { addOutput(output) }
}
}

View File

@ -0,0 +1,57 @@
//
// CaptureSession+MockCaptureSession.swift of MijickCamera
//
// Created by Tomasz Kurylik. Sending from Kraków!
// - Mail: tomasz.kurylik@mijick.com
// - GitHub: https://github.com/FulcrumOne
// - Medium: https://medium.com/@mijick
//
// Copyright ©2024 Mijick. All rights reserved.
import AVKit
extension MockCaptureSession: @unchecked Sendable {}
class MockCaptureSession: NSObject, CaptureSession { required override init() {}
// MARK: Attributes
var isRunning: Bool { _isRunning }
var deviceInputs: [any CaptureDeviceInput] { _deviceInputs }
var outputs: [AVCaptureOutput] { _outputs }
var sessionPreset: AVCaptureSession.Preset = .cif352x288
// MARK: Private Attributes
private var _isRunning: Bool = false
private var _deviceInputs: [any CaptureDeviceInput] = []
private var _outputs: [AVCaptureOutput] = []
}
// MARK: - METHODS
extension MockCaptureSession {
func startRunning() { Task { @MainActor in
_isRunning = true
}}
func stopRunningAndReturnNewInstance() -> any CaptureSession {
_isRunning = false
return MockCaptureSession()
}
}
extension MockCaptureSession {
func add(input: (any CaptureDeviceInput)?) throws(MCameraError) {
guard let input = input as? MockDeviceInput, !_deviceInputs.contains(where: { input == $0 }) else { throw .cannotSetupInput }
_deviceInputs.append(input)
}
func remove(input: (any CaptureDeviceInput)?) {
guard let input = input as? MockDeviceInput, let index = _deviceInputs.firstIndex(where: { $0.device.uniqueID == input.device.uniqueID }) else { return }
_deviceInputs.remove(at: index)
}
}
extension MockCaptureSession {
func add(output: AVCaptureOutput?) throws(MCameraError) {
guard let output, !outputs.contains(output) else { throw .cannotSetupOutput }
_outputs.append(output)
}
}

View File

@ -0,0 +1,27 @@
//
// CaptureSession.swift of MijickCamera
//
// Created by Tomasz Kurylik. Sending from Kraków!
// - Mail: tomasz.kurylik@mijick.com
// - GitHub: https://github.com/FulcrumOne
// - Medium: https://medium.com/@mijick
//
// Copyright ©2024 Mijick. All rights reserved.
import AVKit
protocol CaptureSession: Sendable {
// MARK: Attributes
var isRunning: Bool { get }
var deviceInputs: [any CaptureDeviceInput] { get }
var outputs: [AVCaptureOutput] { get }
var sessionPreset: AVCaptureSession.Preset { get set }
// MARK: Methods
func startRunning()
func stopRunningAndReturnNewInstance() -> CaptureSession
func add(input: (any CaptureDeviceInput)?) throws(MCameraError)
func remove(input: (any CaptureDeviceInput)?)
func add(output: AVCaptureOutput?) throws(MCameraError)
}

View File

@ -0,0 +1,16 @@
//
// Typealiases.swift of MijickCamera
//
// Created by Tomasz Kurylik. Sending from Kraków!
// - Mail: tomasz.kurylik@mijick.com
// - GitHub: https://github.com/FulcrumOne
// - Medium: https://medium.com/@mijick
//
// Copyright ©2024 Mijick. All rights reserved.
import SwiftUI
public typealias CameraScreenBuilder = @MainActor (CameraManager, Namespace.ID, _ closeMCameraAction: @escaping () -> ()) -> any MCameraScreen
public typealias CapturedMediaScreenBuilder = @MainActor (MCameraMedia, Namespace.ID, _ retakeAction: @escaping () -> (), _ acceptMediaAction: @escaping () -> ()) -> any MCapturedMediaScreen
public typealias ErrorScreenBuilder = @MainActor (MCameraError, _ closeMCameraAction: @escaping () -> ()) -> any MCameraErrorScreen

View File

@ -0,0 +1,19 @@
//
// CameraExposure.swift of MijickCamera
//
// Created by Tomasz Kurylik. Sending from Kraków!
// - Mail: tomasz.kurylik@mijick.com
// - GitHub: https://github.com/FulcrumOne
// - Medium: https://medium.com/@mijick
//
// Copyright ©2024 Mijick. All rights reserved.
import AVKit
struct CameraExposure {
var duration: CMTime = .zero
var targetBias: Float = 0
var iso: Float = 0
var mode: AVCaptureDevice.ExposureMode = .autoExpose
}

View File

@ -0,0 +1,28 @@
//
// MCameraMedia.swift of MijickCamera
//
// Created by Tomasz Kurylik. Sending from Kraków!
// - Mail: tomasz.kurylik@mijick.com
// - GitHub: https://github.com/FulcrumOne
// - Medium: https://medium.com/@mijick
//
// Copyright ©2024 Mijick. All rights reserved.
import SwiftUI
public struct MCameraMedia: Sendable {
let image: UIImage?
let video: URL?
init?(data: Any?) {
if let image = data as? UIImage { self.image = image; self.video = nil }
else if let video = data as? URL { self.video = video; self.image = nil }
else { return nil }
}
}
// MARK: Equatable
extension MCameraMedia: Equatable {
public static func == (lhs: MCameraMedia, rhs: MCameraMedia) -> Bool { lhs.image == rhs.image && lhs.video == rhs.video }
}

View File

@ -0,0 +1,68 @@
//
// CameraView+Bridge.swift of MijickCamera
//
// Created by Tomasz Kurylik. Sending from Kraków!
// - Mail: tomasz.kurylik@mijick.com
// - GitHub: https://github.com/FulcrumOne
// - Medium: https://medium.com/@mijick
//
// Copyright ©2024 Mijick. All rights reserved.
import SwiftUI
struct CameraBridgeView: UIViewRepresentable {
let cameraManager: CameraManager
let inputView: UIView = .init()
}
extension CameraBridgeView {
func makeUIView(context: Context) -> some UIView {
cameraManager.initialize(in: inputView)
setupTapGesture(context)
setupPinchGesture(context)
return inputView
}
func updateUIView(_ uiView: UIViewType, context: Context) {}
func makeCoordinator() -> Coordinator { .init(self) }
}
private extension CameraBridgeView {
func setupTapGesture(_ context: Context) {
let tapRecognizer = UITapGestureRecognizer(target: context.coordinator, action: #selector(context.coordinator.onTapGesture))
inputView.addGestureRecognizer(tapRecognizer)
}
func setupPinchGesture(_ context: Context) {
let pinchRecognizer = UIPinchGestureRecognizer(target: context.coordinator, action: #selector(context.coordinator.onPinchGesture))
inputView.addGestureRecognizer(pinchRecognizer)
}
}
// MARK: Equatable
extension CameraBridgeView: Equatable {
nonisolated static func ==(lhs: Self, rhs: Self) -> Bool { true }
}
// MARK: - GESTURES
extension CameraBridgeView { class Coordinator: NSObject { init(_ parent: CameraBridgeView) { self.parent = parent }
let parent: CameraBridgeView
}}
// MARK: On Tap
extension CameraBridgeView.Coordinator {
@MainActor @objc func onTapGesture(_ tap: UITapGestureRecognizer) {
do {
let touchPoint = tap.location(in: parent.inputView)
try parent.cameraManager.setCameraFocus(at: touchPoint)
} catch {}
}
}
// MARK: On Pinch
extension CameraBridgeView.Coordinator {
@MainActor @objc func onPinchGesture(_ pinch: UIPinchGestureRecognizer) { if pinch.state == .changed {
do {
let desiredZoomFactor = parent.cameraManager.attributes.zoomFactor + atan2(pinch.velocity, 33)
try parent.cameraManager.setCameraZoomFactor(desiredZoomFactor)
} catch {}
}}
}

View File

@ -0,0 +1,33 @@
//
// CameraView+FocusIndicator.swift of MijickCamera
//
// Created by Tomasz Kurylik. Sending from Kraków!
// - Mail: tomasz.kurylik@mijick.com
// - GitHub: https://github.com/FulcrumOne
// - Medium: https://medium.com/@mijick
//
// Copyright ©2024 Mijick. All rights reserved.
import SwiftUI
@MainActor class CameraFocusIndicatorView {
var image: UIImage = .init(resource: .mijickIconCrosshair)
var tintColor: UIColor = .init(resource: .mijickBackgroundYellow)
var size: CGFloat = 96
}
// MARK: Create
extension CameraFocusIndicatorView {
func create(at touchPoint: CGPoint) -> UIImageView {
let focusIndicator = UIImageView(image: image)
focusIndicator.contentMode = .scaleAspectFit
focusIndicator.tintColor = tintColor
focusIndicator.frame.size = .init(width: size, height: size)
focusIndicator.frame.origin.x = touchPoint.x - size / 2
focusIndicator.frame.origin.y = touchPoint.y - size / 2
focusIndicator.transform = .init(scaleX: 0, y: 0)
focusIndicator.tag = .focusIndicatorTag
return focusIndicator
}
}

View File

@ -0,0 +1,81 @@
//
// CameraView+Grid.swift of MijickCamera
//
// Created by Tomasz Kurylik. Sending from Kraków!
// - Mail: tomasz.kurylik@mijick.com
// - GitHub: https://github.com/FulcrumOne
// - Medium: https://medium.com/@mijick
//
// Copyright ©2024 Mijick. All rights reserved.
import SwiftUI
class CameraGridView: UIView {
var parent: CameraManager!
}
// MARK: Setup
extension CameraGridView {
func setup(parent: CameraManager) {
self.parent = parent
self.alpha = parent.attributes.isGridVisible ? 1 : 0
self.addToParent(parent.cameraView)
}
}
// MARK: Set Visibility
extension CameraGridView {
func setVisibility(_ isVisible: Bool) {
UIView.animate(withDuration: 0.2) { self.alpha = isVisible ? 1 : 0 }
parent.attributes.isGridVisible = isVisible
}
}
// MARK: Draw
extension CameraGridView {
override func draw(_ rect: CGRect) {
clearOldLayersBeforeDraw()
let firstColumnPath = UIBezierPath()
firstColumnPath.move(to: CGPoint(x: bounds.width / 3, y: 0))
firstColumnPath.addLine(to: CGPoint(x: bounds.width / 3, y: bounds.height))
let firstColumnLayer = createGridLayer()
firstColumnLayer.path = firstColumnPath.cgPath
layer.addSublayer(firstColumnLayer)
let secondColumnPath = UIBezierPath()
secondColumnPath.move(to: CGPoint(x: (2 * bounds.width) / 3, y: 0))
secondColumnPath.addLine(to: CGPoint(x: (2 * bounds.width) / 3, y: bounds.height))
let secondColumnLayer = createGridLayer()
secondColumnLayer.path = secondColumnPath.cgPath
layer.addSublayer(secondColumnLayer)
let firstRowPath = UIBezierPath()
firstRowPath.move(to: CGPoint(x: 0, y: bounds.height / 3))
firstRowPath.addLine(to: CGPoint(x: bounds.width, y: bounds.height / 3))
let firstRowLayer = createGridLayer()
firstRowLayer.path = firstRowPath.cgPath
layer.addSublayer(firstRowLayer)
let secondRowPath = UIBezierPath()
secondRowPath.move(to: CGPoint(x: 0, y: ( 2 * bounds.height) / 3))
secondRowPath.addLine(to: CGPoint(x: bounds.width, y: ( 2 * bounds.height) / 3))
let secondRowLayer = createGridLayer()
secondRowLayer.path = secondRowPath.cgPath
layer.addSublayer(secondRowLayer)
}
}
private extension CameraGridView {
func clearOldLayersBeforeDraw() {
layer.sublayers?.removeAll()
layer.backgroundColor = .none
}
func createGridLayer() -> CAShapeLayer {
let shapeLayer = CAShapeLayer()
shapeLayer.strokeColor = UIColor(white: 1.0, alpha: 0.2).cgColor
shapeLayer.frame = bounds
shapeLayer.fillColor = nil
return shapeLayer
}
}

View File

@ -0,0 +1,279 @@
//
// CameraView+Metal.swift of MijickCamera
//
// Created by Tomasz Kurylik. Sending from Kraków!
// - Mail: tomasz.kurylik@mijick.com
// - GitHub: https://github.com/FulcrumOne
// - Medium: https://medium.com/@mijick
//
// Copyright ©2024 Mijick. All rights reserved.
import SwiftUI
import MetalKit
import AVKit
@MainActor class CameraMetalView: MTKView {
private(set) var parent: CameraManager!
private(set) var ciContext: CIContext!
private(set) var commandQueue: MTLCommandQueue!
private(set) var currentFrame: CIImage?
private(set) var focusIndicator: CameraFocusIndicatorView = .init()
private(set) var isAnimating: Bool = false
}
// MARK: Setup
extension CameraMetalView {
func setup(parent: CameraManager) throws(MCameraError) {
guard let metalDevice = MTLCreateSystemDefaultDevice() else { throw .cannotSetupMetalDevice }
self.assignInitialValues(parent: parent, metalDevice: metalDevice)
self.configureMetalView(metalDevice: metalDevice)
self.addToParent(parent.cameraView)
}
}
private extension CameraMetalView {
func assignInitialValues(parent: CameraManager, metalDevice: MTLDevice) {
self.parent = parent
self.ciContext = CIContext(mtlDevice: metalDevice)
self.commandQueue = metalDevice.makeCommandQueue()
}
func configureMetalView(metalDevice: MTLDevice) {
self.parent.cameraView.alpha = 0
self.delegate = self
self.device = metalDevice
self.isPaused = true
self.enableSetNeedsDisplay = false
self.framebufferOnly = false
self.autoResizeDrawable = false
self.contentMode = .scaleAspectFill
self.clipsToBounds = true
}
}
// MARK: - ANIMATIONS
// MARK: Camera Entrance
extension CameraMetalView {
func performCameraEntranceAnimation() { UIView.animate(withDuration: 0.33) { [self] in
parent.cameraView.alpha = 1
}}
}
// MARK: Image Capture
extension CameraMetalView {
func performImageCaptureAnimation() {
let blackMatte = createBlackMatte()
parent.cameraView.addSubview(blackMatte)
animateBlackMatte(blackMatte)
}
/// Shows screen flash for front camera, calls completion when ready to capture
func performScreenFlash(completion: @escaping @MainActor () -> Void) {
guard let cameraView = parent?.cameraView else {
completion()
return
}
let flashColor = parent.attributes.screenFlashColor ?? .white
let flashView = createScreenFlashView(color: flashColor)
let originalBrightness = UIScreen.main.brightness
// Add flash overlay to the window for full screen coverage
if let window = cameraView.window {
flashView.frame = window.bounds
window.addSubview(flashView)
} else {
flashView.frame = cameraView.bounds
cameraView.addSubview(flashView)
}
// Boost brightness and show flash
UIScreen.main.brightness = 1.0
UIView.animate(withDuration: 0.05, animations: {
flashView.alpha = 1.0
}) { _ in
// Small delay for camera to adjust to new lighting, then capture
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(100))
completion()
// Fade out flash after capture is triggered
try? await Task.sleep(for: .milliseconds(150))
UIView.animate(withDuration: 0.2, animations: {
flashView.alpha = 0
}) { _ in
flashView.removeFromSuperview()
UIScreen.main.brightness = originalBrightness
}
}
}
}
}
private extension CameraMetalView {
func createBlackMatte() -> UIView {
let view = UIView()
view.frame = parent.cameraView.frame
view.backgroundColor = .init(resource: .mijickBackgroundPrimary)
view.alpha = 0
return view
}
func animateBlackMatte(_ view: UIView) {
UIView.animate(withDuration: 0.16, animations: { view.alpha = 1 }) { _ in
UIView.animate(withDuration: 0.16, animations: { view.alpha = 0 }) { _ in
view.removeFromSuperview()
}
}
}
func createScreenFlashView(color: UIColor) -> UIView {
let view = UIView()
view.frame = UIScreen.main.bounds
view.backgroundColor = color
view.alpha = 0
return view
}
}
// MARK: Camera Flip
extension CameraMetalView {
func beginCameraFlipAnimation() async {
let snapshot = createSnapshot()
isAnimating = true
insertBlurView(snapshot)
animateBlurFlip()
await Task.sleep(seconds: 0.01)
}
func finishCameraFlipAnimation() async {
guard let blurView = parent.cameraView.viewWithTag(.blurViewTag) else { return }
await Task.sleep(seconds: 0.44)
UIView.animate(withDuration: 0.3, animations: { blurView.alpha = 0 }) { [self] _ in
blurView.removeFromSuperview()
isAnimating = false
}
}
}
private extension CameraMetalView {
func createSnapshot() -> UIImage? {
guard let currentFrame else { return nil }
let image = UIImage(ciImage: currentFrame)
return image
}
func insertBlurView(_ snapshot: UIImage?) {
let blurView = UIImageView(frame: parent.cameraView.frame)
blurView.image = snapshot
blurView.contentMode = .scaleAspectFill
blurView.clipsToBounds = true
blurView.tag = .blurViewTag
blurView.applyBlurEffect(style: .regular)
parent.cameraView.addSubview(blurView)
}
func animateBlurFlip() {
UIView.transition(with: parent.cameraView, duration: 0.44, options: cameraFlipAnimationTransition) {}
}
}
private extension CameraMetalView {
var cameraFlipAnimationTransition: UIView.AnimationOptions { parent.attributes.cameraPosition == .back ? .transitionFlipFromLeft : .transitionFlipFromRight }
}
// MARK: Camera Focus
extension CameraMetalView {
func performCameraFocusAnimation(touchPoint: CGPoint) {
removeExistingFocusIndicatorAnimations()
let focusIndicator = focusIndicator.create(at: touchPoint)
parent.cameraView.addSubview(focusIndicator)
animateFocusIndicator(focusIndicator)
}
}
private extension CameraMetalView {
func removeExistingFocusIndicatorAnimations() { if let view = parent.cameraView.viewWithTag(.focusIndicatorTag) {
view.removeFromSuperview()
}}
func animateFocusIndicator(_ focusIndicator: UIImageView) {
UIView.animate(withDuration: 0.44, delay: 0, usingSpringWithDamping: 0.6, initialSpringVelocity: 0, animations: { focusIndicator.transform = .init(scaleX: 1, y: 1) }) { _ in
UIView.animate(withDuration: 0.44, delay: 1.44, animations: { focusIndicator.alpha = 0.2 }) { _ in
UIView.animate(withDuration: 0.44, delay: 1.44, animations: { focusIndicator.alpha = 0 })
}
}
}
}
// MARK: Camera Orientation
extension CameraMetalView {
func beginCameraOrientationAnimation(if shouldAnimate: Bool) async { if shouldAnimate {
parent.cameraView.alpha = 0
await Task.sleep(seconds: 0.1)
}}
func finishCameraOrientationAnimation(if shouldAnimate: Bool) { if shouldAnimate {
UIView.animate(withDuration: 0.2, delay: 0.1) { self.parent.cameraView.alpha = 1 }
}}
}
// MARK: - CAPTURING FRAMES
// MARK: Capture
extension CameraMetalView: @preconcurrency AVCaptureVideoDataOutputSampleBufferDelegate {
func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
guard let cvImageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
let currentFrame = captureCurrentFrame(cvImageBuffer)
let currentFrameWithFiltersApplied = applyingFiltersToCurrentFrame(currentFrame)
redrawCameraView(currentFrameWithFiltersApplied)
}
}
private extension CameraMetalView {
func captureCurrentFrame(_ cvImageBuffer: CVImageBuffer) -> CIImage {
let currentFrame = CIImage(cvImageBuffer: cvImageBuffer)
return currentFrame.oriented(parent.attributes.frameOrientation)
}
func applyingFiltersToCurrentFrame(_ currentFrame: CIImage) -> CIImage {
currentFrame.applyingFilters(parent.attributes.cameraFilters)
}
func redrawCameraView(_ frame: CIImage) {
currentFrame = frame
draw()
}
}
// MARK: Draw
extension CameraMetalView: MTKViewDelegate {
func draw(in view: MTKView) {
guard let commandBuffer = commandQueue.makeCommandBuffer(),
let ciImage = currentFrame,
let currentDrawable = view.currentDrawable
else { return }
changeDrawableSize(view, ciImage)
renderView(view, currentDrawable, commandBuffer, ciImage)
commitBuffer(currentDrawable, commandBuffer)
}
func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {}
}
private extension CameraMetalView {
func changeDrawableSize(_ view: MTKView, _ ciImage: CIImage) {
view.drawableSize = ciImage.extent.size
}
func renderView(_ view: MTKView, _ currentDrawable: any CAMetalDrawable, _ commandBuffer: any MTLCommandBuffer, _ ciImage: CIImage) { ciContext.render(
ciImage,
to: currentDrawable.texture,
commandBuffer: commandBuffer,
bounds: .init(origin: .zero, size: view.drawableSize),
colorSpace: CGColorSpaceCreateDeviceRGB()
)}
func commitBuffer(_ currentDrawable: any CAMetalDrawable, _ commandBuffer: any MTLCommandBuffer) {
commandBuffer.present(currentDrawable)
commandBuffer.commit()
}
}

View File

@ -0,0 +1,97 @@
//
// DefaultCameraScreen+BottomBar.swift of MijickCamera
//
// Created by Tomasz Kurylik. Sending from Kraków!
// - Mail: tomasz.kurylik@mijick.com
// - GitHub: https://github.com/FulcrumOne
// - Medium: https://medium.com/@mijick
//
// Copyright ©2024 Mijick. All rights reserved.
import SwiftUI
extension DefaultCameraScreen { struct BottomBar: View {
let parent: DefaultCameraScreen
var body: some View {
ZStack(alignment: .top) {
createOutputTypeSwitch()
createButtons()
}
.frame(maxWidth: .infinity)
.padding(.bottom, 44)
.padding(.horizontal, 32)
}
}}
private extension DefaultCameraScreen.BottomBar {
@ViewBuilder func createOutputTypeSwitch() -> some View { if isOutputTypeSwitchActive {
DefaultCameraScreen.CameraOutputSwitch(parent: parent)
.offset(y: -80)
}}
func createButtons() -> some View {
ZStack {
createLightButton()
createCaptureButton()
createChangeCameraPositionButton()
}.frame(height: 72)
}
}
private extension DefaultCameraScreen.BottomBar {
@ViewBuilder func createLightButton() -> some View { if isLightButtonActive {
BottomButton(
icon: .mijickIconLight,
iconColor: lightButtonIconColor,
backgroundColor: .init(.mijickBackgroundSecondary),
rotationAngle: parent.iconAngle,
action: changeLightMode
)
.frame(maxWidth: .infinity, alignment: .leading)
.transition(.scale)
}}
@ViewBuilder func createCaptureButton() -> some View { if isCaptureButtonActive {
DefaultCameraScreen.CaptureButton(
outputType: parent.cameraOutputType,
isRecording: parent.isRecording,
action: parent.captureOutput
)
.transition(.scale)
}}
@ViewBuilder func createChangeCameraPositionButton() -> some View { if isChangeCameraPositionButtonActive {
BottomButton(
icon: .mijickIconChangeCamera,
iconColor: changeCameraPositionButtonIconColor,
backgroundColor: .init(.mijickBackgroundSecondary),
rotationAngle: parent.iconAngle,
action: changeCameraPosition
)
.frame(maxWidth: .infinity, alignment: .trailing)
.transition(.scale)
}}
}
private extension DefaultCameraScreen.BottomBar {
func changeLightMode() {
do { try parent.setLightMode(parent.lightMode.next()) }
catch {}
}
func changeCameraPosition() { Task {
do { try await parent.setCameraPosition(parent.cameraPosition.next()) }
catch {}
}}
}
private extension DefaultCameraScreen.BottomBar {
var lightButtonIconColor: Color { switch parent.lightMode {
case .on: .init(.mijickBackgroundYellow)
case .off: .init(.mijickBackgroundInverted)
}}
var changeCameraPositionButtonIconColor: Color { .init(.mijickBackgroundInverted) }
}
private extension DefaultCameraScreen.BottomBar {
var isOutputTypeSwitchActive: Bool { parent.config.cameraOutputSwitchAllowed && parent.cameraManager.captureSession.isRunning && !parent.isRecording }
var isLightButtonActive: Bool { parent.config.lightButtonAllowed && parent.hasLight && parent.cameraManager.captureSession.isRunning && !parent.isRecording }
var isCaptureButtonActive: Bool { parent.config.captureButtonAllowed && parent.cameraManager.captureSession.isRunning }
var isChangeCameraPositionButtonActive: Bool { parent.config.cameraPositionButtonAllowed && parent.cameraManager.captureSession.isRunning && !parent.isRecording }
}

View File

@ -0,0 +1,19 @@
//
// DefaultCameraScreen+ButtonScaleStyle.swift of MijickCamera
//
// Created by Tomasz Kurylik. Sending from Kraków!
// - Mail: tomasz.kurylik@mijick.com
// - GitHub: https://github.com/FulcrumOne
// - Medium: https://medium.com/@mijick
//
// Copyright ©2024 Mijick. All rights reserved.
import SwiftUI
struct ButtonScaleStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View { configuration
.label
.scaleEffect(configuration.isPressed ? 0.96 : 1)
}
}

View File

@ -0,0 +1,79 @@
//
// DefaultCameraScreen+CameraOutputSwitch.swift of MijickCamera
//
// Created by Tomasz Kurylik. Sending from Kraków!
// - Mail: tomasz.kurylik@mijick.com
// - GitHub: https://github.com/FulcrumOne
// - Medium: https://medium.com/@mijick
//
// Copyright ©2024 Mijick. All rights reserved.
import SwiftUI
extension DefaultCameraScreen { struct CameraOutputSwitch: View {
let parent: DefaultCameraScreen
var body: some View {
HStack(spacing: 4) {
createOutputTypeButton(.video)
createOutputTypeButton(.photo)
}
.padding(8)
.background(Color(.mijickBackgroundPrimary50))
.mask(Capsule())
}
}}
private extension DefaultCameraScreen.CameraOutputSwitch {
func createOutputTypeButton(_ outputType: CameraOutputType) -> some View {
Button(icon: getOutputTypeButtonIcon(outputType), active: isOutputTypeButtonActive(outputType)) {
parent.setOutputType(outputType)
}
.rotationEffect(parent.iconAngle)
}
}
private extension DefaultCameraScreen.CameraOutputSwitch {
func getOutputTypeButtonIcon(_ outputType: CameraOutputType) -> ImageResource { switch outputType {
case .photo: return .mijickIconPhoto
case .video: return .mijickIconVideo
}}
func isOutputTypeButtonActive(_ outputType: CameraOutputType) -> Bool {
outputType == parent.cameraOutputType
}
}
// MARK: Button
fileprivate struct Button: View {
let icon: ImageResource
let active: Bool
let action: () -> ()
var body: some View {
SwiftUI.Button(action: action, label: createButtonLabel).buttonStyle(ButtonScaleStyle())
}
}
private extension Button {
func createButtonLabel() -> some View {
Image(icon)
.resizable()
.frame(width: iconSize, height: iconSize)
.foregroundColor(iconColor)
.padding(8)
.background(Color(.mijickBackgroundSecondary))
.mask(Circle())
}
}
private extension Button {
var iconSize: CGFloat { switch active {
case true: 28
case false: 20
}}
var iconColor: Color { switch active {
case true: .init(.mijickBackgroundYellow)
case false: .init(.mijickTextTertiary)
}}
}

View File

@ -0,0 +1,55 @@
//
// DefaultCameraScreen+CaptureButton.swift of MijickCamera
//
// Created by Tomasz Kurylik. Sending from Kraków!
// - Mail: tomasz.kurylik@mijick.com
// - GitHub: https://github.com/FulcrumOne
// - Medium: https://medium.com/@mijick
//
// Copyright ©2024 Mijick. All rights reserved.
import SwiftUI
extension DefaultCameraScreen { struct CaptureButton: View {
let outputType: CameraOutputType
let isRecording: Bool
let action: () -> ()
var body: some View {
Button(action: action, label: createButtonLabel).buttonStyle(ButtonScaleStyle())
}
}}
private extension DefaultCameraScreen.CaptureButton {
func createButtonLabel() -> some View {
ZStack {
createBackground()
createBorders()
}.frame(width: 72, height: 72)
}
}
private extension DefaultCameraScreen.CaptureButton {
func createBackground() -> some View {
RoundedRectangle(cornerRadius: backgroundCornerRadius, style: .continuous)
.fill(backgroundColor)
.padding(backgroundPadding)
}
func createBorders() -> some View {
Circle().stroke(Color(.mijickBackgroundInverted), lineWidth: 2.5)
}
}
private extension DefaultCameraScreen.CaptureButton {
var backgroundColor: Color { switch outputType {
case .photo: .init(.mijickBackgroundInverted)
case .video: .init(.mijickBackgroundRed)
}}
var backgroundCornerRadius: CGFloat { switch isRecording {
case true: 6
case false: 36
}}
var backgroundPadding: CGFloat { switch isRecording {
case true: 20
case false: 4
}}
}

View File

@ -0,0 +1,23 @@
//
// DefaultCameraScreen+Config.swift of MijickCamera
//
// Created by Tomasz Kurylik. Sending from Kraków!
// - Mail: tomasz.kurylik@mijick.com
// - GitHub: https://github.com/FulcrumOne
// - Medium: https://medium.com/@mijick
//
// Copyright ©2024 Mijick. All rights reserved.
import Foundation
extension DefaultCameraScreen { class Config {
var captureButtonAllowed: Bool = true
var cameraOutputSwitchAllowed: Bool = true
var cameraPositionButtonAllowed: Bool = true
var flashButtonAllowed: Bool = true
var lightButtonAllowed: Bool = true
var flipButtonAllowed: Bool = true
var gridButtonAllowed: Bool = true
var closeButtonAllowed: Bool = true
}}

View File

@ -0,0 +1,116 @@
//
// DefaultCameraScreen+TopBar.swift of MijickCamera
//
// Created by Tomasz Kurylik. Sending from Kraków!
// - Mail: tomasz.kurylik@mijick.com
// - GitHub: https://github.com/FulcrumOne
// - Medium: https://medium.com/@mijick
//
// Copyright ©2024 Mijick. All rights reserved.
import SwiftUI
extension DefaultCameraScreen { struct TopBar: View {
let parent: DefaultCameraScreen
var body: some View { if isTopBarActive {
ZStack {
createCloseButton()
createCentralView()
createRightSideView()
}
.frame(maxWidth: .infinity)
.padding(.top, topPadding)
.padding(.bottom, 8)
.padding(.horizontal, 20)
.background(Color(.mijickBackgroundPrimary80))
.transition(.move(edge: .top))
}}
}}
private extension DefaultCameraScreen.TopBar {
@ViewBuilder func createCloseButton() -> some View { if isCloseButtonActive {
CloseButton(action: parent.closeMCameraAction)
.frame(maxWidth: .infinity, alignment: .leading)
}}
@ViewBuilder func createCentralView() -> some View { if isCentralViewActive {
Text(parent.recordingTime.toString())
.font(.system(size: 20, weight: .medium))
.foregroundColor(.init(.mijickTextPrimary))
}}
@ViewBuilder func createRightSideView() -> some View { if isRightSideViewActive {
HStack(spacing: 12) {
createGridButton()
createFlipOutputButton()
createFlashButton()
}
.frame(maxWidth: .infinity, alignment: .trailing)
}}
}
private extension DefaultCameraScreen.TopBar {
@ViewBuilder func createGridButton() -> some View { if isGridButtonActive {
DefaultCameraScreen.TopButton(
icon: gridButtonIcon,
iconRotationAngle: parent.iconAngle,
action: changeGridVisibility
)
}}
@ViewBuilder func createFlipOutputButton() -> some View { if isFlipOutputButtonActive {
DefaultCameraScreen.TopButton(
icon: flipButtonIcon,
iconRotationAngle: parent.iconAngle,
action: changeMirrorOutput
)
}}
@ViewBuilder func createFlashButton() -> some View { if isFlashButtonActive {
DefaultCameraScreen.TopButton(
icon: flashButtonIcon,
iconRotationAngle: parent.iconAngle,
action: changeFlashMode
)
}}
}
private extension DefaultCameraScreen.TopBar {
func changeGridVisibility() {
parent.setGridVisibility(!parent.isGridVisible)
}
func changeMirrorOutput() {
parent.setMirrorOutput(!parent.isOutputMirrored)
}
func changeFlashMode() {
parent.setFlashMode(parent.flashMode.next())
}
}
private extension DefaultCameraScreen.TopBar {
var topPadding: CGFloat { switch parent.deviceOrientation {
case .portrait, .portraitUpsideDown: return 40
default: return 20
}}
}
private extension DefaultCameraScreen.TopBar {
var gridButtonIcon: ImageResource { switch parent.isGridVisible {
case true: .mijickIconGridOn
case false: .mijickIconGridOff
}}
var flipButtonIcon: ImageResource { switch parent.isOutputMirrored {
case true: .mijickIconFlipOn
case false: .mijickIconFlipOff
}}
var flashButtonIcon: ImageResource { switch parent.flashMode {
case .off: .mijickIconFlashOff
case .on: .mijickIconFlashOn
case .auto: .mijickIconFlashAuto
}}
}
private extension DefaultCameraScreen.TopBar {
var isTopBarActive: Bool { parent.cameraManager.captureSession.isRunning }
var isCloseButtonActive: Bool { parent.config.closeButtonAllowed && !parent.isRecording }
var isCentralViewActive: Bool { parent.isRecording }
var isRightSideViewActive: Bool { !parent.isRecording }
var isGridButtonActive: Bool { parent.config.gridButtonAllowed }
var isFlipOutputButtonActive: Bool { parent.config.flipButtonAllowed && parent.cameraPosition == .front }
var isFlashButtonActive: Bool { parent.config.flashButtonAllowed && parent.hasFlash && parent.cameraOutputType == .photo }
}

View File

@ -0,0 +1,35 @@
//
// DefaultCameraScreen+TopButton.swift of MijickCamera
//
// Created by Tomasz Kurylik. Sending from Kraków!
// - Mail: tomasz.kurylik@mijick.com
// - GitHub: https://github.com/FulcrumOne
// - Medium: https://medium.com/@mijick
//
// Copyright ©2024 Mijick. All rights reserved.
import SwiftUI
extension DefaultCameraScreen { struct TopButton: View {
let icon: ImageResource
let iconRotationAngle: Angle
let action: () -> ()
var body: some View {
Button(action: action, label: createButtonLabel)
}
}}
private extension DefaultCameraScreen.TopButton {
func createButtonLabel() -> some View {
Image(icon)
.resizable()
.frame(width: 16, height: 16)
.foregroundColor(Color(.mijickBackgroundInverted))
.rotationEffect(iconRotationAngle)
.frame(width: 32, height: 32)
.background(Color(.mijickBackgroundSecondary))
.mask(Circle())
}
}

View File

@ -0,0 +1,54 @@
//
// DefaultCameraScreen.swift of MijickCamera
//
// Created by Tomasz Kurylik. Sending from Kraków!
// - Mail: tomasz.kurylik@mijick.com
// - GitHub: https://github.com/FulcrumOne
// - Medium: https://medium.com/@mijick
//
// Copyright ©2024 Mijick. All rights reserved.
import SwiftUI
public struct DefaultCameraScreen: MCameraScreen {
@ObservedObject public var cameraManager: CameraManager
public let namespace: Namespace.ID
public let closeMCameraAction: () -> ()
var config: Config = .init()
public var body: some View {
ZStack {
createContentView()
createTopBar()
createBottomBar()
}
.ignoresSafeArea()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.mijickBackgroundPrimary).ignoresSafeArea())
.statusBarHidden()
.animation(.mSpring)
}
}
private extension DefaultCameraScreen {
func createTopBar() -> some View {
DefaultCameraScreen.TopBar(parent: self)
.frame(maxHeight: .infinity, alignment: .top)
}
func createContentView() -> some View {
createCameraOutputView()
.ignoresSafeArea()
}
func createBottomBar() -> some View {
DefaultCameraScreen.BottomBar(parent: self)
.frame(maxHeight: .infinity, alignment: .bottom)
}
}
extension DefaultCameraScreen {
var iconAngle: Angle { switch isOrientationLocked {
case true: deviceOrientation.getAngle()
case false: .zero
}}
}

View File

@ -0,0 +1,90 @@
//
// DefaultCapturedMediaScreen.swift of MijickCamera
//
// Created by Tomasz Kurylik. Sending from Kraków!
// - Mail: tomasz.kurylik@mijick.com
// - GitHub: https://github.com/FulcrumOne
// - Medium: https://medium.com/@mijick
//
// Copyright ©2024 Mijick. All rights reserved.
import SwiftUI
import AVKit
struct DefaultCapturedMediaScreen: MCapturedMediaScreen {
let capturedMedia: MCameraMedia
let namespace: Namespace.ID
let retakeAction: () -> ()
let acceptMediaAction: () -> ()
@State private var player: AVPlayer = .init()
@State private var isInitialized: Bool = false
var body: some View {
ZStack {
createContentView()
createButtons()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.mijickBackgroundPrimary).ignoresSafeArea())
.animation(.mSpring, value: isInitialized)
.onAppear { isInitialized = true }
}
}
private extension DefaultCapturedMediaScreen {
@ViewBuilder func createContentView() -> some View { if isInitialized {
if let image = capturedMedia.getImage() { createImageView(image) }
else if let video = capturedMedia.getVideo() { createVideoView(video) }
}}
func createButtons() -> some View {
HStack(spacing: 32) {
createRetakeButton()
createSaveButton()
}
.padding(.top, 12)
.padding(.bottom, 4)
.frame(maxHeight: .infinity, alignment: .bottom)
.padding(.bottom, 8)
}
}
private extension DefaultCapturedMediaScreen {
func createImageView(_ image: UIImage) -> some View {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
.ignoresSafeArea()
.transition(.scale(scale: 1.1))
}
func createVideoView(_ video: URL) -> some View {
VideoPlayer(player: player)
.onAppear { onVideoAppear(video) }
}
@ViewBuilder func createRetakeButton() -> some View { if isInitialized {
BottomButton(
icon: .mijickIconCancel,
iconColor: .init(.mijickBackgroundInverted),
backgroundColor: .init(.mijickBackgroundSecondary),
rotationAngle: .zero,
action: retakeAction
)
.transition(.scale)
}}
@ViewBuilder func createSaveButton() -> some View { if isInitialized {
BottomButton(
icon: .mijickIconCheck,
iconColor: .init(.mijickBackgroundPrimary),
backgroundColor: .init(.mijickBackgroundInverted),
rotationAngle: .zero,
action: acceptMediaAction
)
.transition(.scale)
}}
}
private extension DefaultCapturedMediaScreen {
func onVideoAppear(_ url: URL) {
player = .init(url: url)
player.play()
}
}

View File

@ -0,0 +1,37 @@
//
// DefaultScreen+BottomButton.swift of MijickCamera
//
// Created by Tomasz Kurylik. Sending from Kraków!
// - Mail: tomasz.kurylik@mijick.com
// - GitHub: https://github.com/FulcrumOne
// - Medium: https://medium.com/@mijick
//
// Copyright ©2024 Mijick. All rights reserved.
import SwiftUI
struct BottomButton: View {
let icon: ImageResource
let iconColor: Color
let backgroundColor: Color
let rotationAngle: Angle
let action: () -> ()
var body: some View {
Button(action: action, label: createButtonLabel).buttonStyle(ButtonScaleStyle())
}
}
private extension BottomButton {
func createButtonLabel() -> some View {
Image(icon)
.resizable()
.frame(width: 26, height: 26)
.foregroundColor(iconColor)
.rotationEffect(rotationAngle)
.frame(width: 52, height: 52)
.background(backgroundColor)
.mask(Circle())
}
}

Some files were not shown because too many files have changed in this diff Show More