├── .gitignore
├── .swiftpm
└── xcode
│ └── package.xcworkspace
│ ├── contents.xcworkspacedata
│ └── xcshareddata
│ └── IDEWorkspaceChecks.plist
├── Images
└── preview.gif
├── LICENSE
├── Package.swift
├── README.md
└── Sources
└── SwiftUIDigitalSignature
└── SwiftUIDigitalSignature.swift
/.gitignore:
--------------------------------------------------------------------------------
1 | # Xcode
2 | #
3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
4 |
5 | ## User settings
6 | xcuserdata/
7 |
8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
9 | *.xcscmblueprint
10 | *.xccheckout
11 |
12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
13 | build/
14 | DerivedData/
15 | *.moved-aside
16 | *.pbxuser
17 | !default.pbxuser
18 | *.mode1v3
19 | !default.mode1v3
20 | *.mode2v3
21 | !default.mode2v3
22 | *.perspectivev3
23 | !default.perspectivev3
24 |
25 | ## Obj-C/Swift specific
26 | *.hmap
27 |
28 | ## App packaging
29 | *.ipa
30 | *.dSYM.zip
31 | *.dSYM
32 |
33 | ## Playgrounds
34 | timeline.xctimeline
35 | playground.xcworkspace
36 |
37 | # Swift Package Manager
38 | #
39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
40 | # Packages/
41 | # Package.pins
42 | # Package.resolved
43 | # *.xcodeproj
44 | #
45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
46 | # hence it is not needed unless you have added a package configuration file to your project
47 | # .swiftpm
48 |
49 | .build/
50 |
51 | # CocoaPods
52 | #
53 | # We recommend against adding the Pods directory to your .gitignore. However
54 | # you should judge for yourself, the pros and cons are mentioned at:
55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
56 | #
57 | # Pods/
58 | #
59 | # Add this line if you want to avoid checking in source code from the Xcode workspace
60 | # *.xcworkspace
61 |
62 | # Carthage
63 | #
64 | # Add this line if you want to avoid checking in source code from Carthage dependencies.
65 | # Carthage/Checkouts
66 |
67 | Carthage/Build/
68 |
69 | # Accio dependency management
70 | Dependencies/
71 | .accio/
72 |
73 | # fastlane
74 | #
75 | # It is recommended to not store the screenshots in the git repo.
76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed.
77 | # For more information about the recommended setup visit:
78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control
79 |
80 | fastlane/report.xml
81 | fastlane/Preview.html
82 | fastlane/screenshots/**/*.png
83 | fastlane/test_output
84 |
85 | # Code Injection
86 | #
87 | # After new code Injection tools there's a generated folder /iOSInjectionProject
88 | # https://github.com/johnno1962/injectionforxcode
89 |
90 | iOSInjectionProject/
91 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Images/preview.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/globulus/swiftui-digital-signature/e4cc06d0f6c785dcb69ffc60cc1ffbd40eb02e18/Images/preview.gif
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Gordan Glavaš
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.3
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "SwiftUIDigitalSignature",
8 | platforms: [
9 | .iOS(.v13)
10 | ],
11 | products: [
12 | // Products define the executables and libraries a package produces, and make them visible to other packages.
13 | .library(
14 | name: "SwiftUIDigitalSignature",
15 | targets: ["SwiftUIDigitalSignature"]),
16 | ],
17 | dependencies: [
18 | // Dependencies declare other packages that this package depends on.
19 | // .package(url: /* package url */, from: "1.0.0"),
20 | ],
21 | targets: [
22 | // Targets are the basic building blocks of a package. A target can define a module or a test suite.
23 | // Targets can depend on other targets in this package, and on products in packages this package depends on.
24 | .target(
25 | name: "SwiftUIDigitalSignature",
26 | dependencies: [])
27 | ]
28 | )
29 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # SwiftUIDigitalSignature
2 |
3 | **Plug'n' play Digital Signatures in SwiftUI**.
4 |
5 | ## Features
6 |
7 | * Freeform signature drawing.
8 | * Selecting signature image.
9 | * Typing signature in oblique font.
10 | * Choose signature color.
11 | * Choose signature font.
12 | * Callback produces `UIImage` that you can save/use.
13 | * iOS 13/14 compatible.
14 |
15 | 
16 |
17 | ## Installation
18 |
19 | This component is distributed as a **Swift package**. Just add the repo URL to your package list:
20 |
21 | ```text
22 | https://github.com/globulus/swiftui-digital-signature
23 | ```
24 |
25 | ## Sample usage
26 |
27 | ```swift
28 | struct SignatureViewTest: View {
29 | @State private var image: UIImage? = nil
30 |
31 | var body: some View {
32 | NavigationView {
33 | VStack {
34 | NavigationLink("GO", destination: SignatureView(availableTabs: [.draw, .image, .type],
35 | onSave: { image in
36 | self.image = image
37 | }, onCancel: {
38 |
39 | }))
40 | if image != nil {
41 | Image(uiImage: image!)
42 | }
43 | }
44 | }
45 | }
46 | }
47 | ```
48 |
49 | ## Changelog
50 |
51 | * 0.1.2 - Added `availableTabs` initializer param.
52 | * 0.1.1 - Fixed drawing bounds.
53 | * 0.1.0 - Initial release.
54 |
55 | Check out [SwiftUIRecipes.com](https://swiftuirecipes.com) for more SwiftUI solutions, tips and custom components!
56 |
--------------------------------------------------------------------------------
/Sources/SwiftUIDigitalSignature/SwiftUIDigitalSignature.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SignatureView.swift
3 | // SwiftUI Recipes
4 | //
5 | // Created by Gordan Glavaš on 28.06.2021..
6 | //
7 |
8 | import SwiftUI
9 | import CoreGraphics
10 | import UIKit
11 |
12 | private let fontFamlies = ["Zapfino", "SavoyeLetPlain", "SnellRoundhand", "SnellRoundhand-Black"]
13 | private let bigFontSize: CGFloat = 44
14 | private let placeholderText = "Signature"
15 | private let maxHeight: CGFloat = 160
16 | private let lineWidth: CGFloat = 5
17 |
18 | public struct SignatureView: View {
19 | public let availableTabs: [Tab]
20 | public let onSave: (UIImage) -> Void
21 | public let onCancel: () -> Void
22 |
23 | @State private var selectedTab: Tab
24 |
25 | @State private var saveSignature = false
26 |
27 | @State private var fontFamily = fontFamlies[0]
28 | @State private var color = Color.blue
29 |
30 | @State private var drawing = DrawingPath()
31 | @State private var image = UIImage()
32 | @State private var isImageSet = false
33 | @State private var text = ""
34 |
35 | public init(availableTabs: [Tab] = Tab.allCases,
36 | onSave: @escaping (UIImage) -> Void,
37 | onCancel: @escaping () -> Void) {
38 | self.availableTabs = availableTabs
39 | self.onSave = onSave
40 | self.onCancel = onCancel
41 |
42 | self.selectedTab = availableTabs.first!
43 | }
44 |
45 | public var body: some View {
46 | VStack {
47 | HStack {
48 | Button("Done", action: extractImageAndHandle)
49 | Spacer()
50 | Button("Cancel", action: onCancel)
51 | }
52 | if availableTabs.count > 1 {
53 | Picker(selection: $selectedTab, label: EmptyView()) {
54 | ForEach(availableTabs, id: \.self) { tab in
55 | Text(tab.title)
56 | .tag(tab)
57 | }
58 | }.pickerStyle(SegmentedPickerStyle())
59 | }
60 | signatureContent
61 | Button("Clear signature", action: clear)
62 | HStack {
63 | if selectedTab == Tab.type {
64 | FontFamilyPicker(selection: $fontFamily)
65 | }
66 | ColorPickerCompat(selection: $color)
67 | }
68 | Spacer()
69 | }.padding()
70 | }
71 |
72 | private var signatureContent: some View {
73 | return Group {
74 | if selectedTab == .draw {
75 | SignatureDrawView(drawing: $drawing,
76 | fontFamily: $fontFamily,
77 | color: $color)
78 | } else if selectedTab == .image {
79 | SignatureImageView(isSet: $isImageSet, selection: $image)
80 | } else if selectedTab == .type {
81 | SignatureTypeView(text: $text,
82 | fontFamily: $fontFamily,
83 | color: $color)
84 | }
85 | }.padding(.vertical)
86 | }
87 |
88 | private func extractImageAndHandle() {
89 | let image: UIImage
90 | switch selectedTab {
91 | case .draw:
92 | let path = drawing.cgPath
93 | let maxX = drawing.points.map { $0.x }.max() ?? 0
94 | let renderer = UIGraphicsImageRenderer(size: CGSize(width: maxX, height: maxHeight))
95 | let uiImage = renderer.image { ctx in
96 | ctx.cgContext.setStrokeColor(color.uiColor.cgColor)
97 | ctx.cgContext.setLineWidth(lineWidth)
98 | ctx.cgContext.beginPath()
99 | ctx.cgContext.addPath(path)
100 | ctx.cgContext.drawPath(using: .stroke)
101 | }
102 | image = uiImage
103 | case .image:
104 | image = self.image
105 | case .type:
106 | let rendererWidth: CGFloat = 512
107 | let rendererHeight: CGFloat = 128
108 | let renderer = UIGraphicsImageRenderer(size: CGSize(width: rendererWidth, height: rendererHeight))
109 | let uiImage = renderer.image { ctx in
110 | let paragraphStyle = NSMutableParagraphStyle()
111 | paragraphStyle.alignment = .center
112 |
113 | let attrs = [NSAttributedString.Key.font: UIFont(name: fontFamily, size: bigFontSize)!,
114 | NSAttributedString.Key.foregroundColor: color.uiColor,
115 | NSAttributedString.Key.paragraphStyle: paragraphStyle
116 | ]
117 | text.draw(with: CGRect(x: 0, y: 0, width: rendererWidth, height: rendererHeight),
118 | options: .usesLineFragmentOrigin,
119 | attributes: attrs,
120 | context: nil)
121 | }
122 | image = uiImage
123 | }
124 | if saveSignature {
125 | if let data = image.pngData(),
126 | let docsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first {
127 | let filename = docsDir.appendingPathComponent("Signature-\(Date()).png")
128 | try? data.write(to: filename)
129 | }
130 | }
131 | onSave(image)
132 | }
133 |
134 | private func clear() {
135 | drawing = DrawingPath()
136 | image = UIImage()
137 | isImageSet = false
138 | text = ""
139 | }
140 |
141 | public enum Tab: CaseIterable, Hashable {
142 | case draw, image, type
143 |
144 | var title: LocalizedStringKey {
145 | switch self {
146 | case .draw:
147 | return "Draw"
148 | case .image:
149 | return "Image"
150 | case .type:
151 | return "Type"
152 | }
153 | }
154 | }
155 | }
156 |
157 | struct ColorPickerCompat: View {
158 | @Binding var selection: Color
159 |
160 | @State private var showPopover = false
161 | private let availableColors: [Color] = [.blue, .black, .red]
162 |
163 | var body: some View {
164 | if #available(iOS 14.0, *) {
165 | ColorPicker(selection: $selection) {
166 | EmptyView()
167 | }
168 | } else {
169 | Button(action: {
170 | showPopover.toggle()
171 | }, label: {
172 | colorCircle(selection)
173 | }).popover(isPresented: $showPopover) {
174 | ForEach(availableColors, id: \.self) { color in
175 | Button(action: {
176 | selection = color
177 | showPopover.toggle()
178 | }, label: {
179 | colorCircle(color)
180 | })
181 | }
182 | }
183 | }
184 | }
185 |
186 | private func colorCircle(_ color: Color) -> some View {
187 | Circle()
188 | .foregroundColor(color)
189 | .frame(width: 32, height: 32)
190 | }
191 | }
192 |
193 | struct FontFamilyPicker: View {
194 | @Binding var selection: String
195 |
196 | @State private var showPopover = false
197 |
198 | var body: some View {
199 | Button(action: {
200 | showPopover.toggle()
201 | }, label: {
202 | buttonLabel(selection, size: 16)
203 | }).popover(isPresented: $showPopover) {
204 | VStack(spacing: 20) {
205 | ForEach(fontFamlies, id: \.self) { fontFamily in
206 | Button(action: {
207 | selection = fontFamily
208 | showPopover.toggle()
209 | }, label: {
210 | buttonLabel(fontFamily, size: 24)
211 | })
212 | }
213 | }
214 | }
215 | }
216 |
217 | private func buttonLabel(_ fontFamily: String, size: CGFloat) -> Text {
218 | Text(placeholderText)
219 | .font(.custom(fontFamily, size: size))
220 | .foregroundColor(.black)
221 | }
222 | }
223 |
224 | struct FramePreferenceKey: PreferenceKey {
225 | static var defaultValue: CGRect = .zero
226 |
227 | static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
228 | value = nextValue()
229 | }
230 | }
231 |
232 | struct SignatureDrawView: View {
233 | @Binding var drawing: DrawingPath
234 | @Binding var fontFamily: String
235 | @Binding var color: Color
236 |
237 | @State private var drawingBounds: CGRect = .zero
238 |
239 | var body: some View {
240 | ZStack {
241 | Color.white
242 | .background(GeometryReader { geometry in
243 | Color.clear.preference(key: FramePreferenceKey.self,
244 | value: geometry.frame(in: .local))
245 | })
246 | .onPreferenceChange(FramePreferenceKey.self) { bounds in
247 | drawingBounds = bounds
248 | }
249 | if drawing.isEmpty {
250 | Text(placeholderText)
251 | .foregroundColor(.gray)
252 | .font(.custom(fontFamily, size: bigFontSize))
253 | } else {
254 | DrawShape(drawingPath: drawing)
255 | .stroke(lineWidth: lineWidth)
256 | .foregroundColor(color)
257 | }
258 | }
259 | .frame(height: maxHeight)
260 | .gesture(DragGesture()
261 | .onChanged( { value in
262 | if drawingBounds.contains(value.location) {
263 | drawing.addPoint(value.location)
264 | } else {
265 | drawing.addBreak()
266 | }
267 | }).onEnded( { value in
268 | drawing.addBreak()
269 | }))
270 | .overlay(RoundedRectangle(cornerRadius: 4)
271 | .stroke(Color.gray))
272 | }
273 | }
274 |
275 | struct DrawingPath {
276 | private(set) var points = [CGPoint]()
277 | private var breaks = [Int]()
278 |
279 | var isEmpty: Bool {
280 | points.isEmpty
281 | }
282 |
283 | mutating func addPoint(_ point: CGPoint) {
284 | points.append(point)
285 | }
286 |
287 | mutating func addBreak() {
288 | breaks.append(points.count)
289 | }
290 |
291 | var cgPath: CGPath {
292 | let path = CGMutablePath()
293 | guard let firstPoint = points.first else { return path }
294 | path.move(to: firstPoint)
295 | for i in 1.. Path {
326 | drawingPath.path
327 | }
328 | }
329 |
330 | struct SignatureImageView: View {
331 | @Binding var isSet: Bool
332 | @Binding var selection: UIImage
333 |
334 | @State private var showPopover = false
335 |
336 | var body: some View {
337 | Button(action: {
338 | showPopover.toggle()
339 | }) {
340 | if isSet {
341 | Image(uiImage: selection)
342 | .resizable()
343 | .frame(maxHeight: maxHeight)
344 | } else {
345 | ZStack {
346 | Color.white
347 | Text("Choose signature image")
348 | .font(.system(size: 18))
349 | .foregroundColor(.gray)
350 | }.frame(height: maxHeight)
351 | .overlay(RoundedRectangle(cornerRadius: 4)
352 | .stroke(Color.gray))
353 | }
354 | }.popover(isPresented: $showPopover) {
355 | ImagePicker(selectedImage: $selection, didSet: $isSet)
356 | }
357 | }
358 | }
359 |
360 | struct ImagePicker: UIViewControllerRepresentable {
361 | @Environment(\.presentationMode) private var presentationMode
362 | @Binding var selectedImage: UIImage
363 | @Binding var didSet: Bool
364 | var sourceType = UIImagePickerController.SourceType.photoLibrary
365 |
366 | func makeUIViewController(context: UIViewControllerRepresentableContext) -> UIImagePickerController {
367 | let imagePicker = UIImagePickerController()
368 | imagePicker.navigationBar.tintColor = .clear
369 | imagePicker.allowsEditing = false
370 | imagePicker.sourceType = sourceType
371 | imagePicker.delegate = context.coordinator
372 | return imagePicker
373 | }
374 |
375 | func updateUIViewController(_ uiViewController: UIImagePickerController,
376 | context: UIViewControllerRepresentableContext) { }
377 |
378 | func makeCoordinator() -> Coordinator {
379 | Coordinator(self)
380 | }
381 |
382 | class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
383 | let control: ImagePicker
384 |
385 | init(_ control: ImagePicker) {
386 | self.control = control
387 | }
388 |
389 | func imagePickerController(_ picker: UIImagePickerController,
390 | didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
391 | if let image = info[UIImagePickerController.InfoKey.originalImage] as? UIImage {
392 | control.selectedImage = image
393 | control.didSet = true
394 | }
395 | control.presentationMode.wrappedValue.dismiss()
396 | }
397 | }
398 | }
399 |
400 | struct SignatureTypeView: View {
401 | @Binding var text: String
402 | @Binding var fontFamily: String
403 | @Binding var color: Color
404 |
405 | var body: some View {
406 | TextField(placeholderText, text: $text)
407 | .disableAutocorrection(true)
408 | .font(.custom(fontFamily, size: bigFontSize))
409 | .foregroundColor(color)
410 | }
411 | }
412 |
413 | struct SignatureViewTest: View {
414 | @State private var image: UIImage? = nil
415 |
416 | var body: some View {
417 | NavigationView {
418 | VStack {
419 | NavigationLink("GO", destination: SignatureView(availableTabs: [.draw, .image, .type], onSave: { image in
420 | self.image = image
421 | }, onCancel: {
422 |
423 | }))
424 | if image != nil {
425 | Image(uiImage: image!)
426 | }
427 | }
428 | }
429 | }
430 | }
431 |
432 | struct SignatureView_Previews: PreviewProvider {
433 | static var previews: some View {
434 | SignatureViewTest()
435 | }
436 | }
437 |
438 | extension Color {
439 | var uiColor: UIColor {
440 | if #available(iOS 14, *) {
441 | return UIColor(self)
442 | } else {
443 | let components = self.components
444 | return UIColor(red: components.r, green: components.g, blue: components.b, alpha: components.a)
445 | }
446 | }
447 |
448 | private var components: (r: CGFloat, g: CGFloat, b: CGFloat, a: CGFloat) {
449 | let scanner = Scanner(string: self.description.trimmingCharacters(in: CharacterSet.alphanumerics.inverted))
450 | var hexNumber: UInt64 = 0
451 | var r: CGFloat = 0.0, g: CGFloat = 0.0, b: CGFloat = 0.0, a: CGFloat = 0.0
452 | let result = scanner.scanHexInt64(&hexNumber)
453 | if result {
454 | r = CGFloat((hexNumber & 0xff000000) >> 24) / 255
455 | g = CGFloat((hexNumber & 0x00ff0000) >> 16) / 255
456 | b = CGFloat((hexNumber & 0x0000ff00) >> 8) / 255
457 | a = CGFloat(hexNumber & 0x000000ff) / 255
458 | }
459 | return (r, g, b, a)
460 | }
461 | }
462 |
--------------------------------------------------------------------------------