PDF Capture App Part 5: Defeating the Boss
Itās time. Iāve teased this challenger for two blog posts now, and i'tās time to reveal who it is. And it isā¦
THE FILES APP!
Yep, there is a handy button in the Files app that will let you use the same PDF scanner Iām using to save to any place in your files, just like us. That means that, for as many taps as my app takes, you can upload the PDFs directly to whatever file or cloud service is connected to your device.
Dang it! This was exactly what I wanted, and built! So, is it all for naught? Did I make an app that does exactly what the Files app does, except that the Files app is already on the phone and iPad? Is there no more value I can offer?
Well, as so often happens, new discoveries come to light, new competitors enter during development, etc. Itās a fact of entrepreneurial life. And our little app project is no exception. Even though Apple itself still advertises scanning via Notes, and although our app at least does better than that, the Files app feature is amazing.
And now for a great truism in app development:
1) Everything worth doing has been done.
2) Not everything worth doing has been done well.
3) Everything can be done better.
Often people stop at the first point and turn around. Others push forward and bring new value to the world. And thatās what we will do. Weāll create something that has people say, well yes, I could use the Apple default but it isnāt as good as PDFTrapper because ___.
So what can fill those blanks?
It could look a little nicer, and arrange things better. A better design goes a long way. And by design I donāt just mean colors and aesthetics but arranging and setting a up a flow that makes more sense, is less prone to error, and overall does a better job.
However, with our simple app, weāre probably not going to optimize the flow much more, or make design improvements that would cause someone to download the app. Itās about as simple and slimmed down as it can be.
And it isnāt pretty yet! Once everything is functional, weāll give an aesthetics pass to make sure our app has all the best looks and feels.
(More importantly, weāll want to give our app the best accessibility, but that will be for another time).
Even once our app looks good, aesthetics wonāt be enough to sell it.
So we go to the next thing you can do: which is, simply, more. You can add more features that help the user accomplish what they want.
As awesome as the Files scanner is, there is one thing it canāt do, and which we can make our app do. Itās something significant. Something that most folks would want who do a lot of scanning.
The new feature
Weāre going to let the user delete and add more pages. It sounds so simple. But think about it. Youāre doing your taxes and youāve got several pages for one form. You scan four of them, but then, oh no! You forgot to scan one! Or you got one out of order! All you need to do is delete one here, and scan the other one here. Or maybe you forgot to scan one last page. You just need to add that last page to your already-scanned four pages.
But you canāt š.
You canāt because all you have is the Files scanner. And that scanner wonāt let you append a page or delete a page to an existing scan. You can create a brand new PDF from a scan. Thatās it. Nothing else. Now youāve got to spend 5 times as long correcting your mistaking than you would if you only had ā¦
PDFTrapper! Which lets users delete pages and add new pages at any point in the document!
Thatās the idea. Now letās make it happen.
The code
First, letās adjust our PDFManager to include the PDFTrapperDocument itself. And weāll have a handy variable that calculates the number of pages. And weāre going to do a bit of rewriting to make our navigation handling a little more scalable, so weāll add an enum for the different ways we can add pages, and the different views that can be available to us on the main screen. And finally weāll have a āpageNumberForInsertionā that weāll use if one of āAddPDFOptionā is active.
Long story short, the code now looks like this:
import Combine
import PDFKit
import SwiftUI
class PDFManager: ObservableObject {
enum PDFTrapperNavigation {
case showEditor
case showScanner
case showPDFMain
case showAddRemovePages
case refreshing
}
enum AddPDFOption {
case photoRoll
case file
case camera
case none
}
var document: PDFTrapperDocument?
@Published var pageNumberForInsertion = 0
@Published var addPDFOption: AddPDFOption = .none
@Published var navigation: PDFTrapperNavigation = .showPDFMain
var saveReporter = PassthroughSubject<Void, Never>()
var startEditMode = PassthroughSubject<Void, Never>()
var pageCount: Int {
document?.pdf.pageCount ?? 0
}
func addPhotos(_ photos: [UIImage]) {
guard let pdfTrapperDocument = document else {
return
}
let pages = photos.compactMap { PDFPage(image: $0) }
if addPDFOption == .camera {
for i in 0 ..< pages.count {
pdfTrapperDocument.pdf.insert(pages[i], at: pageNumberForInsertion + i)
}
}
if pdfTrapperDocument.isBlank {
// Remove the default blank page
pdfTrapperDocument.pdf.removePage(at: 0)
for i in 0 ..< pages.count {
pdfTrapperDocument.pdf.insert(pages[i], at: i)
}
}
if !pages.isEmpty {
save()
}
}
func delete(page: Int) {
guard let document = document else {
return
}
document.pdf.removePage(at: page - 1)
save()
}
func save() {
saveReporter.send()
}
}
Now weāll make our view to add and remove pages. Weāll use Picker to choose what page to delete and what insertion index to use.
And weāll adjust the text to make our indexās understandable to normal people. So the last index can be āAppend to the end of documentā and such, instead of just the count. And weāll increment the index by one to represent the page number.
Weāll use a Form view to fit in nicely with the system utility aesthetic.
The code looks like this:
import SwiftUI
struct AddRemovePagesView: View {
var pageCount: Int
@ObservedObject var manager: PDFManager
@State var pageToDelete = 1
var pageToDeleteString: String {
NumberFormatter.localizedString(from: NSNumber(integerLiteral: pageToDelete), number: .none)
}
var pageToInsertString: String {
text(forOption: manager.pageNumberForInsertion)
}
func text(forOption option: Int) -> String {
if option == pageCount {
return "Append to the end"
}
let generalPage = NumberFormatter.localizedString(from: NSNumber(integerLiteral: option + 1), number: .none)
return "Insert above page " + generalPage
}
var body: some View {
VStack {
HStack(alignment: .top) {
Button(action: {
withAnimation {
manager.navigation = .showPDFMain
}
}) {
Image(systemName: "xmark")
.font(Font.body.weight(.bold))
}
Spacer()
Text("Page Managerment")
.font(.title)
.bold()
}
.padding()
Form {
Section(header: Text("Page to delete")) {
Picker("Number \(pageToDeleteString)", selection: $pageToDelete) {
ForEach(1...pageCount, id: \.self) { value in
Text("\(value)").tag(value)
}
}
.pickerStyle(MenuPickerStyle())
Button("Delete Page") {
manager.delete(page: pageToDelete)
}
}
Section(header: Text("Page insertion placement")) {
Picker(pageToInsertString, selection: $manager.pageNumberForInsertion) {
ForEach(0...pageCount, id: \.self) { value in
Text(text(forOption: value)).tag(value)
}
}
.pickerStyle(MenuPickerStyle())
}
Section(header: Text("Insertion options")) {
HStack(spacing: 32) {
Button(action: {
manager.addPDFOption = .photoRoll
}) {
Image(systemName: "photo")
.font(.title)
}
Button(action: {
manager.addPDFOption = .file
}) {
Image(systemName: "folder")
.font(.title)
}
Button(action: {
manager.addPDFOption = .camera
manager.navigation = .showScanner
}) {
Image(systemName: "camera")
.font(.title)
}
}
.padding(.horizontal)
}
}
}
.background(Color(.systemGroupedBackground).ignoresSafeArea())
}
}
And that gives us:
Awesome.
And one more thing to do is to move our controls, the buttons to edit and manage PDF pages, to a new view. It looks like this:
import SwiftUI
struct PDFMainControls: View {
@ObservedObject var manager = PDFManager()
var body: some View {
HStack {
Button("Editing Canvas") {
withAnimation {
manager.navigation = .showEditor
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
manager.startEditMode.send()
}
}
Button("Add/Remove Pages") {
withAnimation {
manager.navigation = .showAddRemovePages
}
}
}
}
}
And finally we adjust our ContentView with several improvements.
import Combine
import PDFKit
import SwiftUI
struct ContentView: View {
@StateObject var manager = PDFManager()
@Binding var document: PDFTrapperDocument
var fileURL: URL?
private func save() {
document.setAsNotBlank()
document.saveTrigger.toggle()
refresh()
}
private func refresh() {
withAnimation {
manager.navigation = .refreshing
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
manager.navigation = .showPDFMain
}
}
var body: some View {
ZStack {
if let url = fileURL {
QuickLookController(startEditing: manager.startEditMode, url: url) {
if let refreshedPDF = PDFDocument(url: url) {
document.pdf = refreshedPDF
save()
}
}
.opacity(0)
}
switch manager.navigation {
case .showPDFMain:
if !document.isBlank {
VStack {
PDFDisplayView(pdf: document.pdf)
PDFMainControls(manager: manager)
}
}
case .showScanner:
ScannerView { images in
manager.addPhotos(images)
}
case .showAddRemovePages:
AddRemovePagesView(pageCount: manager.pageCount, manager: manager)
case .refreshing, .showEditor:
ProgressView()
}
}
.onAppear {
manager.document = document
if document.isBlank {
withAnimation {
manager.navigation = .showScanner
}
}
}
.onReceive(manager.saveReporter, perform: save)
}
}
One thing weāve done here is added the different sections in a handy switch statement. However, we donāt include the QuickLookController. Thatās because we want the QuickLookController to always be available to push the actual preview controller, and the QuickLookController is invisible.
Another excellent thing weāve done is attach a refresh mechanism, so that whenever we save our document, the first thing that happens is that we get a progress view and a fade, and that gives our app time to refetch the edited PDF.
That gives us this!
Awesome, everything is working!
You may notice that there is filler for choosing a photo via the image picker and via the document picker. I leave these for you to implement on your own. My strategy: the same as the QuickLookController: rather than wrapping the views directly, have the UIDocumentPickerViewController and the UIImagePickerController be pushed by an invisible view controller so that more of the appropriate UIKit environment is around them.
If you missed this novel, but pro way of wrapping specialty view controllers in SwiftUI, checkout my last post here.
Next Steps
Now we have an app that is more convenient and efficient than Notes, as capable as Files, and with more helpful features than Files.
So, is it time to actually make the app look good?
Getting close, but now that weāre here, letās do something really cool. And I mean, itās the kind of feature that people actually subscribe to high-priced apps for because, up until recently, it has been really hard to do.
What is that feature? Tune in next time! And hit me up at wattmaller1 on Twitter for thoughts and suggestions! Until next time, happy coding.