Have you ever searched for a solution to open PDFs in your Xamarin Forms apps? There are a few third-party libraries available for this, but most require you to purchase a license. Of course, you can find solutions that are free, but less powerful, like this one. What most of these plugins have in common is that they offer you a way to display and manage pdf files and offer some form of customization to the plugin. But most of the time do not allow you embed the plugin’s components into your Xamarin Forms pages, or to fully control every aspect of your reader. Or, open directly the pdf in Xamarin forms pages.
What if you can’t pay a license and you want a pdf reader you control totally, one that you can use to open a pdf in Xamarin forms pages directly? One that you can manage bookmarks yourself, control navigation to a specific page, and make your own thumbnails if you like? This article is here to answer these questions.
If you find this article useful, please follow me on Twitter, Github, Linkedin, or like my Facebook page to stay updated.Follow me on social media and stay updated
Creating a Xamarin iOS PDF view
Since we are building a Xamarin Forms app, what we want to do is create a custom renderer for the iOS project. To learn more about custom renderers, you can follow this link. This custom renderer will natively be an instance of “PdfKit.PdfView”. This view is responsible for displaying pdf documents and permitting the user to interact with these documents.
First, in the shared project, we will create a PDFReaderView class. This class will be the shared view that abstracts our custom renderer. It will have the appropriate properties and Commands to open encrypted pdfs (PDFs that need password), Detect page changes, trigger the switch to a specific page from the code, and Load thumbnails in an asynchronous fashion so that the UI won’t freeze if we are loading thumbnails for a book with thousands of pages, etc.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | public class PDFReaderView : TextReader { public static readonly BindableProperty PageCountSetCommandProperty = BindableProperty.Create(nameof(PageCountSetCommand), typeof(ICommand), typeof(PDFReaderView), default(ICommand)); public ICommand PageCountSetCommand { get { return (ICommand)GetValue(PageCountSetCommandProperty); } set { SetValue(PageCountSetCommandProperty, value); } } public static readonly BindableProperty PDFTappedCommandProperty = BindableProperty.Create(nameof(PDFTappedCommand), typeof(ICommand), typeof(PDFReaderView), default(ICommand)); public ICommand PDFTappedCommand { get { return (ICommand)GetValue(PDFTappedCommandProperty); } set { SetValue(PDFTappedCommandProperty, value); } } public static readonly BindableProperty ThumbNailsCreatedCommandProperty = BindableProperty.Create(nameof(ThumbNailsCreatedCommand), typeof(ICommand), typeof(PDFReaderView), default(ICommand)); public ICommand ThumbNailsCreatedCommand { get { return (ICommand)GetValue(ThumbNailsCreatedCommandProperty); } set { SetValue(ThumbNailsCreatedCommandProperty, value); } } public static readonly BindableProperty BookMarkPageProperty = BindableProperty.Create(nameof(BookMarkPage), typeof(int), typeof(PDFReaderView), default(int)); //Note, this property is important. Check the renderers public int BookMarkPage { get { return (int)GetValue(BookMarkPageProperty); } set { SetValue(BookMarkPageProperty, value); } } } |
Both the PDF Reader and the thumb view we will see later will inherit from the TextReader below. They have some properties in common, we will talk about these later.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 | public class TextReader : View { public static readonly BindableProperty CurrentPageProperty = BindableProperty.Create(nameof(CurrentPage), typeof(int), typeof(TextReader), default(int)); public int CurrentPage { get { return (int)GetValue(CurrentPageProperty); } set { SetValue(CurrentPageProperty, value); } } public static readonly BindableProperty FilePathProperty = BindableProperty.Create(nameof(FilePath), typeof(string), typeof(TextReader), default(string)); public string FilePath { get { return (string)GetValue(FilePathProperty); } set { SetValue(FilePathProperty, value); } } public static readonly BindableProperty PasswordProperty = BindableProperty.Create(nameof(Password), typeof(string), typeof(TextReader), default(Type)); public string Password { get { return (string)GetValue(PasswordProperty); } set { SetValue(PasswordProperty, value); } } public static readonly BindableProperty PageChangedCommandProperty = BindableProperty.Create(nameof(PageChangedCommand), typeof(ICommand), typeof(TextReader), default(ICommand)); public ICommand PageChangedCommand { get { return (ICommand)GetValue(PageChangedCommandProperty); } set { SetValue(PageChangedCommandProperty, value); } } } |
Creating the Xamarin PDFView Custom renderer on iOS
As I mention earlier, we have to create a custom renderer, in this custom renderer, the first step will be to create the custom native control. Which in our case is “PdfKit.PdfView”.
1 2 3 4 5 6 7 8 9 10 11 12 | var pdfViewer = readerView; var pdfView = new PdfKit.PdfView(); var rect = UIScreen.MainScreen.Bounds; pdfView.Frame = new CGRect(rect.X, rect.Y, rect.Width, rect.Height); SetNativeControl(pdfView); Element.GestureRecognizers.Clear(); Element.GestureRecognizers.Add(new TapGestureRecognizer { Command = Element.PDFTappedCommand }); |
Inside this custom renderer, we need to handle the opening of PDF files. For this, we create a function that takes the PDFView, the PDF’s path and its password. Then opens the PDF file. In this method, we create a PDFDocument object, that will give us information about the document such as its number of pages.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | async void Open(PdfView control, string path, string password) { if (!string.IsNullOrEmpty(path) && !string.IsNullOrEmpty(password)) { _document = new PdfDocument(NSUrl.CreateFileUrl(path, null)); var unlocked = _document.Unlock(password); control.Document = _document; control.AutoScales = true; control.UserInteractionEnabled = true; control.BackgroundColor = UIColor.Gray; if (!unlocked) _logger.LogError($"Failed to unlock pdf Product File Path: {path}"); if (Element.PageCountSetCommand != null && Element.PageCountSetCommand.CanExecute(_document.PageCount)) Element.PageCountSetCommand.Execute((int)_document.PageCount); _logger.LogInformation("Starting a new thread to load thumbnails without lagging on UI."); new Thread(CreateThumbnails).Start(); } } |
The next step is to handle property changes, when the password or the path to the PDF is set, we need to open the PDF and display it to users.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | protected override void OnElementPropertyChanged(object sender, PropertyChangedEventArgs e) { base.OnElementPropertyChanged(sender, e); try { if (Control != null) { Control.SetNeedsDisplay(); if (e.PropertyName == nameof(Element.BookMarkPage)) { var page = _document.GetPage(Element.BookMarkPage); Control.GoToPage(page); } else if (e.PropertyName == nameof(Element.CurrentPage)) { var page = _document.GetPage(Element.CurrentPage); Control.GoToPage(page); } else if (e.PropertyName == nameof(Element.FilePath) || e.PropertyName == nameof(Element.Password)) { Open(Control, Element.FilePath, Element.Password); } } } catch (ObjectDisposedException ex) { _logger.LogError(ex, $"Object was disposed while setting property: {e.PropertyName}"); } } |
Generating thumbnails
The PDF kit has a control named: “PDFThumbnailView” that permits us to show a thumbnail for PDFs displayed in the PDF view. We Won’t use this control. I have tried using this control, and it it’s not malleable, and not user friendly at all. I think the iOS team should improve it.
Instead, we will create our own thumb view using Xamarin Form’s collection view, and a trick I’ll talk about later. Then we will combine both our thumb view and pdf view to display PDF in Xamarin Forms Pages.
The trick to display PDF thumbnails is, to use the instance of PDFDocument that we created when we opened the PDF. We then use the instance of this document to get each page of the PDF. For each page, we generate an image that represents its thumbnail. Then, we convert this native UIIMage image to a byte array that will be displayed above the PDF in Xamarin Forms Page.
We have to do all this asynchronously so that the UI does not block. Since we want this process to be non-blocking, while the page of the pdf is converted to thumbnail images, we need to display a list of empty pages to the user. We do this in the ViewModel when the “PageCountSetCommand” is fired. This is how we create our thumbnails,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | private void CreateThumbnails() { var thumbNailImages = new List<byte[]>(); for (int i = 0; i < _document.PageCount; i++) { var page = _document.GetPage(i); var thumbNailImage = page.GetThumbnail(new CGSize(200, 250), PdfDisplayBox.Art); var image = thumbNailImage.AsPNG().AsStream(); var memoryStream = new MemoryStream(); image.CopyTo(memoryStream); var array = memoryStream.ToArray(); thumbNailImages.Add(array); } Device.BeginInvokeOnMainThread(() => { if (Element != null && Element.ThumbNailsCreatedCommand != null && Element.ThumbNailsCreatedCommand.CanExecute(thumbNailImages)) { Element.ThumbNailsCreatedCommand.Execute(thumbNailImages); } }); } |
We call this method when the PDF is opened in a new thread, to make it non-blocking.
1 | new Thread(CreateThumbnails).Start(); |
Once the thumbnails are created, we fire the “ThumbNailsCreatedCommand” that will notify our view model that the thumbnails are ready to be displayed.
1 2 3 4 5 6 7 8 9 10 11 | public ObservableCollection<PDFThumbnailImageData> ThumbnailImages { get; set; } void OnExecuteThumbnailCreated(List<byte[]> images) { for (int i = 0; i < images.Count; i++) { ThumbnailImages[i].ImageData = images[i]; } } |
The list of byte arrays of thumbnails added to an observable list of type “PDFThumbnailImageData” in the viewmodel. Here is what this class looks like.
1 2 3 4 5 6 7 8 9 10 | public class PDFThumbnailImageData : ReactiveObject { [Reactive] public byte[] ImageData { get; set; } [Reactive] public int Index { get; set; } [Reactive] public bool IsSelected { get; set; } } |
To convert the byte array of image into an image source, we use a value converter. Here is what this converter looks like.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | public class ImageFromStreamConverter : IValueConverter { public object Convert(object value, Type targetType, object parameter, CultureInfo cultureInfo) { var source = value as byte[]; //if the value passed does not cast to a string, //Dont continue and return Null if (source == null) return null; var memStream = new MemoryStream(source); var imgSource = ImageSource.FromStream(() => memStream); return imgSource; } public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) { throw new NotImplementedException(); } } |
As mentioned above, the process of converting each page in a thumbnail can be blocking, and while it is taking place, we need to display a thumb view to the user, representing each page. To do this, we populate the observable collection of ThumbnailImageData with a page number when the “PageCountSetCommand” is called, which will be displayed to the user. This will function normally, except that no image will be displayed.
1 2 3 4 5 6 7 8 9 10 11 12 13 | void OnExecutePageCountSetCommand(int pageCount) { PageCount = pageCount; for (int i = 1; i <= pageCount; i++) { ThumbnailImages.Add(new PDFThumbnailImageData { Index = i }); } } |
On our Xamarin Forms page, the thumbnail will be a collection view. Here is how it will be represented in XAML.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 | <CollectionView Grid.Row="2" x:Name="iOSThumbView" HeightRequest="100" IsVisible="{OnPlatform Default=True, Android=False}" ItemsLayout="HorizontalList" HorizontalScrollBarVisibility="Never" Margin="0,0,0,15" ItemsSource="{Binding ThumbnailImages}"> <CollectionView.ItemTemplate> <DataTemplate x:DataType="helpers1:PDFThumbnailImageData"> <Grid HeightRequest="100"> <Grid.GestureRecognizers> <TapGestureRecognizer Command="{Binding ThumbnailSelectedCommand, Source={RelativeSource AncestorType={x:Type viewmodels:PDFReaderViewModel}}}" CommandParameter="{Binding Index}"/> </Grid.GestureRecognizers> <BoxView BackgroundColor="{StaticResource PrimaryAppColor}" VerticalOptions="Center" HeightRequest="100" Margin="3,0"/> <Image Source="{Binding ImageData, Converter={StaticResource ImageFromStreamConverter}}" Aspect="Fill" WidthRequest="75" VerticalOptions="Center" HeightRequest="100" Margin="3,0"/> <Label FontSize="35" FontFamily="{StaticResource BoldTextFont}" HorizontalOptions="Center" HorizontalTextAlignment="Center" VerticalOptions="Center" Text="{Binding Index}" TextColor="{StaticResource AccentColor}" VerticalTextAlignment="Center"/> <BoxView Opacity="0.4" VerticalOptions="Center" HeightRequest="100" Margin="3,0" BackgroundColor="{StaticResource AccentColor}" IsVisible="{Binding IsSelected}"/> </Grid> </DataTemplate> </CollectionView.ItemTemplate> </CollectionView> |
Here is the complete code for our PDF renderer.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 | public class PDFViewerRenderer : ViewRenderer<PDFReaderView, PdfKit.PdfView> { private PdfDocument _document; ILogger<PDFViewerRenderer> _logger; private NSObject _notificationToken = null; int _pageChangeCount = 0; public PDFViewerRenderer() { _logger = MobileApp.Setup.ServiceProvider.GetRequiredService<ILogger<PDFViewerRenderer>>(); } protected override void OnElementChanged(ElementChangedEventArgs<PDFReaderView> e) { base.OnElementChanged(e); CreateNativeView(Element); } void CreateNativeView(PDFReaderView readerView) { if (Control == null) { var pdfViewer = readerView; var pdfView = new PdfKit.PdfView(); var rect = UIScreen.MainScreen.Bounds; pdfView.Frame = new CGRect(rect.X, rect.Y, rect.Width, rect.Height); if (!string.IsNullOrEmpty(pdfViewer.FilePath) && !string.IsNullOrEmpty(pdfViewer.Password)) { Open(pdfView, Element.FilePath, Element.Password); } if (Element.CurrentPage > 0) { var page = _document.GetPage(Element.CurrentPage); pdfView.GoToPage(page); } _notificationToken = PdfView.Notifications.ObserveVisiblePagesChanged(new EventHandler<NSNotificationEventArgs>((s, eargs) => { if (_pageChangeCount > 0) { if (Control != null) { var currentPage = (int) _document.GetPageIndex(Control.CurrentPage); if (Element.PageChangedCommand != null && Element.PageChangedCommand.CanExecute(currentPage)) { Element.PageChangedCommand.Execute(currentPage); } } } _pageChangeCount++; })); SetNativeControl(pdfView); Element.GestureRecognizers.Clear(); Element.GestureRecognizers.Add(new TapGestureRecognizer { Command = Element.PDFTappedCommand }); } } async void Open(PdfView control, string path, string password) { if (!string.IsNullOrEmpty(path) && !string.IsNullOrEmpty(password)) { _document = new PdfDocument(NSUrl.CreateFileUrl(path, null)); var unlocked = _document.Unlock(password); control.Document = _document; control.AutoScales = true; control.UserInteractionEnabled = true; control.BackgroundColor = UIColor.Gray; if (!unlocked) _logger.LogError($"Failed to unlock pdf Product File Path: {path}"); if (Element.PageCountSetCommand != null && Element.PageCountSetCommand.CanExecute(_document.PageCount)) Element.PageCountSetCommand.Execute((int)_document.PageCount); _logger.LogInformation("Starting a new thread to load thumbnails without lagging on UI."); new Thread(CreateThumbnails).Start(); } } private void CreateThumbnails() { var thumbNailImages = new List<byte[]>(); for (int i = 0; i < _document.PageCount; i++) { var page = _document.GetPage(i); var thumbNailImage = page.GetThumbnail(new CGSize(200, 250), PdfDisplayBox.Art); var image = thumbNailImage.AsPNG().AsStream(); var memoryStream = new MemoryStream(); image.CopyTo(memoryStream); var array = memoryStream.ToArray(); thumbNailImages.Add(array); } Device.BeginInvokeOnMainThread(() => { if (Element != null && Element.ThumbNailsCreatedCommand != null && Element.ThumbNailsCreatedCommand.CanExecute(thumbNailImages)) { Element.ThumbNailsCreatedCommand.Execute(thumbNailImages); } }); } protected override void OnElementPropertyChanged(object sender, PropertyChangedEventArgs e) { base.OnElementPropertyChanged(sender, e); try { if (Control != null) { Control.SetNeedsDisplay(); if (e.PropertyName == nameof(Element.BookMarkPage)) { var page = _document.GetPage(Element.BookMarkPage); Control.GoToPage(page); } else if (e.PropertyName == nameof(Element.CurrentPage)) { var page = _document.GetPage(Element.CurrentPage); Control.GoToPage(page); } else if (e.PropertyName == nameof(Element.FilePath) || e.PropertyName == nameof(Element.Password)) { Open(Control, Element.FilePath, Element.Password); } } } catch (ObjectDisposedException ex) { _logger.LogError(ex, $"Object was disposed while setting property: {e.PropertyName}"); } } protected override void Dispose(bool disposing) { try { if (_document != null) { _document = null; } if (_notificationToken != null) { _notificationToken.Dispose(); } Control.Dispose(); base.Dispose(disposing); } catch (Exception ex) { return; } } } |
To use this renderer in XAML, we instantiate it as follows:
1 2 3 4 5 6 7 8 9 10 11 12 | <customviews:PDFReaderView BookMarkPage="{Binding BookmarkPage}" PageChangedCommand="{Binding PageChangedCommand}" PageCountSetCommand="{Binding PageCountSetCommand}" PDFTappedCommand="{Binding PDFTappedCommand}" FilePath="{Binding FilePath}" Password="{Binding PassWord}" CurrentPage="{Binding CurrentPage}" Grid.Row="0" Grid.RowSpan="3" ThumbNailsCreatedCommand="{Binding ThumbnailCreatedCommand}" Margin="0,0,0,0" x:Name="PDFReaderView"/> |
Conclusion
As we saw in this article, we have almost everything at our disposal to open and manipulate a PDF in Xamarin Forms. We didn’t need any third-party library in the process. Next, we will see how to read PDF files on the android side of things. Stay tuned and don’t miss out. You might also like this article about creating a custom Skeleton loader in Xamarin Forms.
Follow me on social media and stay updated
Beyza Gevrek