The SwiftUI way of importing a file from the device

Handling PDF import states in SwiftUI by enums

Mobile@Exxeta
Mobile App Circular
9 min readJun 13, 2024

--

Photo by Anete Lusina from Pexels

There are many instances where a user may want to import a PDF into an app. However, during the importing process, different phases may occur with varying results. For example, the PDF could initially be in a loading state, but after some time, it might fail to load, resulting in an error. To represent the various states of this process and simultaneously update the views when the state changes, we can use an enum. In this article, we will demonstrate how to implement a PDF importer in SwiftUI.

PDF importer functions

Our PDF importer implements various functions. Our initial step is to display the native file importer. Following this, we transition to a loading state until the selected PDF is fully loaded. In case an error occurs during the loading process, we present an error message. Once the PDF is successfully loaded, we proceed to show it in a preview. Additionally, if the user closes the file importer without selecting a PDF, we display an empty state.

To achieve this, we will create:

  1. Enum: represent states
  2. ViewModel: manage and store states
  3. Views: display pdfs, error, spinner and empty state

PDF importer state enum — Reflect the states

For the PDF import process, we have four different states:

  1. Empty: user did not select a PDF
  2. Loading: user did select a PDF and it’s loading
  3. Loaded: user did select a PDF and it should be displayed
  4. Error: user did select a PDF and an error occurred

The resulting enum looks like this:

enum PdfImporterState: Equatable { 

case loading
case error(PDFError)
case loaded(PDFDocument)
case empty

static func == (
lhs: PdfImporterViewModel.PdfImporterState,
rhs: PdfImporterViewModel.PdfImporterState
) -> Bool {
switch (lhs, rhs) {
case (.loading, .loading):
return true
case (.loaded, .loaded):
return false
case (.error(let lhsError), .error(let rhsError)):
return lhsError.prexif == rhsError.prexif
case (.empty, .empty):
return true
default:
return false
}
}
}

We created an enum with the 4 specified cases. For error we can set a specific error, like for example when the selected PDF file is corrupt. For loaded we can set the selected pdf. To achieve automatic reloading by diffing, we make the enum Equatable and define when a case should be the equal.

ViewModel

For updating the view by state changes of the PDFImporterState we create a view model as an ObservableObject.

class PdfImporterViewModel: ObservableObject { 
@Published var pdfImporterState: PdfImporterState = .empty
}

The view model for now only holds a Published variable, PDFImporterState, with the default state .empty.

Views

To start the implementation of the UI, we create a main view, which we name PDFImporterView. Additionally, we create the views PDFImporterLoadingView, PDFImporterLoadedView, PDFImporterErrorView, EmptyPDFImporterView. In the PDFImporterView we can now define which view to display based on the PDFImporterState.

struct PdfImporterView: View { 

@StateObject var viewModel: PdfImporterViewModel = PdfImporterViewModel()

var body: some View {
ZStack {
switch viewModel.pdfImporterState {
case .loading:
PdfImporterLoadingView()
case .loaded(let selecedPdf):
PdfImporterLoadedView(
selectedPdf: selecedPdf,
backToFileImportAction: viewModel.showPdfImporter
)
case .error(let error):
PdfImporterErrorView(
pdfError: error,
backToFileImportAction: viewModel.showPdfImporter
)
case .empty:
EmptyPdfImporterView(
backToFileImportAction: viewModel.showPdfImporter
)
}
}
}
}

In the view, we instantiate our view model as a StateObject. Through this, we can access the PDFImporterState. By switching through the different states, we can assign them the needed view. For the loaded state, we can directly retrieve the selected PDF and pass it to the PDFImporterLoadedView. Similarly, we handle the error state in the same manner.

PdfImporterLoadedView

Because SwiftUI does not have its own PDF viewer, we use the PDFView from UIKit. For using a UIView in SwiftUI, we need to write a UIViewRepresentable like this:

struct PDFKitView: UIViewRepresentable { 
let pdf: PDFDocument

func makeUIView(context: UIViewRepresentableContext<PDFKitView>) -> PDFView {
let pdfView = PDFView()
pdfView.autoScales = true
return pdfView
}

func updateUIView(_ uiView: PDFView, context: UIViewRepresentableContext<PDFKitView>) {
DispatchQueue.main.async {
uiView.document = pdf
}
}
}

When initializing the view, we set the selected PDF. In makeUIView we create the PDFView and set it to autoscale, to let UIKit handle the sizing of the views. In updateUIView we set the selected PDF as the document of the view. We perform the update on the main thread, because the file importer call will be from the background. We assign the PDF in the update function to ensure that the user can also change the selected PDF and the view updates accordingly.

Furthermore, we aim to create a view positioned above the PDFKitView to change the PDF. To achieve this, we’ll design a bottom sheet with a button, a white background, and a shadow effect.

struct PdfImporterBottomSheet: View { 
var backToFileImportAction: (() -> Void)?
var body: some View {
VStack(spacing: 0) {
Button(
Texts.importAnotherPdf,
action: backToFileImportAction ?? {}
)
.buttonStyle(.borderedProminent)
}
.frame(maxWidth: .infinity)
.padding(.vertical, Dimens.unit20)
.padding(.horizontal, Dimens.unit20)
.background(.white)
.background(
Color.white
.shadow(
color: Color.black.opacity(Dimens.opacity16),
radius: Dimens.unit8
)
)
}
}

Later, when the user clicks on the button, we want to open the file importer. For this, we initialize the view with the function backToFileImportAction. It should be optional because in some cases we do not want to enable this function.

After creating these two components, we will put them together in a VStack:

struct PdfImporterLoadedView: View { 
let selectedPdf: PDFDocument
let backToFileImportAction: () -> Void

var body: some View {
VStack(spacing: 0) {
PDFKitView(pdf: selectedPdf)
PdfImporterBottomSheet(
backToFileImportAction: backToFileImportAction
)
}
}
}

We initialize the view with the selected pdf and the backToFileImportAction function. Now we have a view for previewing the selected PDF.

PdfImporterLoadingView

Some PDFs can be big and will take some time to load. To create a good user experience for the user, we want to show a loading view.

struct PdfImporterLoadingView: View { 
var body: some View {
VStack(alignment: .center, spacing: 0) {
Spacer()
ProgressView()
.frame(width: Dimens.unit56, height: Dimens.unit56)
.padding(.vertical, Dimens.unit16)
.padding(.bottom, Dimens.unit8)
Text(Texts.loadingPdf)
Spacer()
PdfImporterBottomSheet()
}
.frame(maxWidth: .infinity)
}
}

The PDFImporterLoadingView contains a progress view and an information text. Furthermore, we use the PDFImporterBottomSheet again. But this time we do not assign the backToFileImportAction because when the PDF is loading, we do not want the user to select another PDF.

EmptyPdfImporterView

When the user closes the file importer without selecting a PDF we want to show a view with a message, that he did not select a PDF and a button to import PDF. The view looks like this:

struct EmptyPdfImporterView: View { 
let backToFileImportAction: () -> Void

var body: some View {
VStack(spacing: 0) {
Spacer()
Image(ImageNames.empty)
.resizable()
.frame(
width: Dimens.imageSize,
height: Dimens.imageSize
)
.scaledToFit()
.padding(.bottom, Dimens.unit40)
Text(Texts.emptyPdfTitle)
.font(.title)
.multilineTextAlignment(.center)
.padding(.bottom, Dimens.unit16)
Text(Texts.emptyPdfDescription)
.multilineTextAlignment(.center)
Spacer()
Button(
Texts.backtToFileImportButtonTitle,
action: backToFileImportAction
)
.buttonStyle(.borderedProminent)
.padding(.bottom, Dimens.unit16)
}
.padding(.horizontal, Dimens.unit20)
.preferredColorScheme(.light)
}
}

PdfImporterErrorView

The last view which we need is the PDFImporterErrorView. This view will be used to display an error, which can occur when importing the selected pdf. To achieve this, we start with creating a protocol for a generic error in the app.

protocol PDFError { 
var prexif: String { get }
var illustrationName: String { get }
var title: String { get }
var description: String { get }
var buttonTitle: String { get }
}

The error has an illustrationName, a title, a description and a buttonTitle. The prefix is for distinguishing different errors.

Now we create a specific error for importing PDFs:

struct PDFImportError: PDFError, Error { 
var prexif: String = "PDFImportError"
var illustrationName: String = ImageNames.error
var title: String = Texts.importErrorTitle
var description: String = Texts.importErrorDescription
var buttonTitle: String = Texts.importAnotherPdf
}

For this, we create a struct with the PDFError protocol and Error. Then set the properties.

Now we can create the PDFImporterErrorView:

struct PdfImporterErrorView: View { 
let pdfError: PDFError
let backToFileImportAction: () -> Void

var body: some View {
VStack(spacing: 0) {
Spacer()
Image(pdfError.illustrationName)
.resizable()
.scaledToFit()
.frame(height: Dimens.imageSize)
.padding(.bottom, Dimens.unit40)
Text(pdfError.title)
.font(.title)
.padding(.bottom, Dimens.unit16)
Text(pdfError.description)
.multilineTextAlignment(.center)
Spacer()
Button(
pdfError.buttonTitle,
action: backToFileImportAction
)
.buttonStyle(.borderedProminent)
.padding(.bottom, Dimens.unit16)
}
.padding(.horizontal, Dimens.unit20)
.background(.white)
}
}

We initialize the view with the backToFileImportAction and a PDFError. By using the protocol, we ensure that we can also display other errors of this type. The view displays the defined image, title and description. Like all other Views it also holds the button for the backToFileImportAction.

Importing PDFs

For importing PDFs, we can use the modifier. To show the file importer when we need it, we define a new Published variable, isPresentingPdfImport, in the view model with a corresponding function for showing it.

class PdfImporterViewModel: ObservableObject { 
@Published var isPresentingPdfImport: Bool = true
@Published var pdfImporterState: PdfImporterState = .empty

/// Shows pdf importer
public func showPdfImporter() {
isPresentingPdfImport = true
}
}

In the PDFImporterView we now call the fileImporter modifier on the ZStack. To manage the presenting of the importer, we assign the published variable from the view model isPresentingPdfImport as a Binding. Now, if the variable is true, the file importer will be displayed.

ZStack { 

}
.fileImporter(
isPresented: $viewModel.isPresentingPdfImport,
allowedContentTypes: [.pdf]
) { result in
viewModel.pdfWasSelected(result: result)
}

The last step remaining is to listen to the results of the file importer. For this, we use the onCompletion of the file importer and call the function pdfWasSelected from the view model and pass the result of the onCompletion as a parameter.

In the view model we add the following function:

public func pdfWasSelected(result: Result<URL, Error>) { 
pdfImporterState = .loading
/// Load pdf in background
switch result {
case .success(let url):
Task {
/// Check Security
guard
url.startAccessingSecurityScopedResource(),
let pdfDocument = PDFDocument(url: url)
else {
/// startAccessingSecurityScopedResource returns false e.g. when trying to upload virus
url.stopAccessingSecurityScopedResource()
Task.detached { @MainActor in
self.pdfImporterState = .error(PDFImportError())
}
return
}

Task.detached { @MainActor in
self.pdfImporterState = .loaded(pdfDocument)
}
url.stopAccessingSecurityScopedResource()
}
case .failure:
/// Error from apple fileimporter (corrupt files for e.g.)
pdfImporterState = .error(PDFImportError())
}
}

We begin by loading the PDF in the background using Task. Following this, we switch for the results. For success, we receive the URL, where the PDF is stored on the device, and create a PDFDocument from it. It’s important to use startAccessingSecurityScopedResource to ensure the PDF is free from security issues. If any issues arise, we set the pdfImporterState to error on the main thread, with our created PDFImportError. If the PDF is valid, we set the pdfImporterState to loaded on the main thread, with the pdfDocument obtained from the URL. It’s important to call stopAccessingSecurityScopedResource to ensure correct access to user files. In case of failure within the switch statement, we again set pdfImporterState to our defined error state on the main thread.

Conclusion

This covers all you need for importing PDFs using file importing and effectively communicating the different states to the user.

If you’re interested in exploring a comprehensive example and encountering alternative error scenarios, check out our GitHub repository:

Ready to dive in and see how it works for yourself? Give our example code a try, and we’re excited to see how you implement different states with the use of enums. (By Laura Siewert)

--

--

Passionate people @ Exxeta. Various topics around building great solutions for mobile devices. We enjoy: creating | sharing | exchanging. mobile@exxeta.com