on master help sign in
Implement native iOS photo picker using PHPickerViewController instead of file picker
commit7a81a43061f23b521638e9edb6d3066fc53d0c8f authoriko <mail@iko.soy> dateTue 14 Apr 2026, 04:56 (6 months ago) parent177a234
4 files changed+120−44
client/App.fs+67−44 client/Platform/iOS/Info.plist+2 client/Platform/iOS/PhotoPicker.fs+50 client/Skrepka.fsproj+1
client/App.fs+67−44
@@ -516,8 +516,74 @@ module App =
516516 match app.ApplicationLifetime with
517517 | :? Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime as desktop ->
518518 desktop.MainWindow |> Option.ofObj |> Option.map (fun w -> w.StorageProvider)
519+ | :? Avalonia.Controls.ApplicationLifetimes.ISingleViewApplicationLifetime as singleView ->
520+ singleView.MainView
521+ |> Option.ofObj
522+ |> Option.bind (fun v -> Avalonia.Controls.TopLevel.GetTopLevel(v) |> Option.ofObj)
523+ |> Option.map (fun tl -> tl.StorageProvider)
519524 | _ -> None
520525
526+ let private cropToSquarePng (imageBytes: byte[]) =
527+ use skBmp = SkiaSharp.SKBitmap.Decode(imageBytes)
528+ let size = min skBmp.Width skBmp.Height
529+ let x = (skBmp.Width - size) / 2
530+ let y = (skBmp.Height - size) / 2
531+ use cropped = new SkiaSharp.SKBitmap(size, size)
532+ skBmp.ExtractSubset(cropped, SkiaSharp.SKRectI(x, y, x + size, y + size)) |> ignore
533+ use img = SkiaSharp.SKImage.FromBitmap(cropped)
534+ use data = img.Encode(SkiaSharp.SKEncodedImageFormat.Png, 90)
535+ data.ToArray()
536+
537+#if MOBILE
538+ let private pickPhotoCmd =
539+ Cmd.ofEffect (fun dispatch ->
540+ task {
541+ try
542+ let! bytes = Skrepka.iOS.PhotoPicker.pick ()
543+
544+ match bytes with
545+ | Some b -> dispatch (PhotoPicked(Some(cropToSquarePng b)))
546+ | None -> dispatch (PhotoPicked None)
547+ with _ ->
548+ dispatch (PhotoPicked None)
549+ }
550+ |> ignore)
551+#else
552+ let private pickPhotoCmd =
553+ Cmd.ofEffect (fun dispatch ->
554+ Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(fun () ->
555+ task {
556+ try
557+ match getStorageProvider () with
558+ | Some sp ->
559+ let opts =
560+ Avalonia.Platform.Storage.FilePickerOpenOptions(
561+ AllowMultiple = false,
562+ Title = "Pick Photo",
563+ FileTypeFilter =
564+ [ Avalonia.Platform.Storage.FilePickerFileType(
565+ "Images",
566+ Patterns = [ "*.png"; "*.jpg"; "*.jpeg"; "*.gif"; "*.bmp" ]
567+ ) ]
568+ )
569+
570+ let! files = sp.OpenFilePickerAsync(opts)
571+
572+ match files |> Seq.tryHead with
573+ | None -> dispatch (PhotoPicked None)
574+ | Some file ->
575+ use! stream = file.OpenReadAsync()
576+ use ms = new MemoryStream()
577+ do! stream.CopyToAsync(ms)
578+ dispatch (PhotoPicked(Some(cropToSquarePng (ms.ToArray()))))
579+ | None -> dispatch (PhotoPicked None)
580+ with _ ->
581+ dispatch (PhotoPicked None)
582+ }
583+ :> System.Threading.Tasks.Task)
584+ |> ignore)
585+#endif
586+
521587 let mapCmd cmdMsg =
522588 match cmdMsg with
523589 | CmdConnect(url, identity) ->
@@ -634,50 +700,7 @@ module App =
634700
635701 | CmdSaveData data -> Cmd.ofEffect (fun _ -> Store.saveData data)
636702
637− | CmdPickPhoto ->
638− Cmd.ofEffect (fun dispatch ->
639− Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(fun () ->
640− task {
641− try
642− match getStorageProvider () with
643− | Some sp ->
644− let opts =
645− Avalonia.Platform.Storage.FilePickerOpenOptions(
646− AllowMultiple = false,
647− Title = "Pick Photo",
648− FileTypeFilter =
649− [ Avalonia.Platform.Storage.FilePickerFileType(
650− "Images",
651− Patterns = [ "*.png"; "*.jpg"; "*.jpeg"; "*.gif"; "*.bmp" ]
652− ) ]
653− )
654−
655− let! files = sp.OpenFilePickerAsync(opts)
656−
657− match files |> Seq.tryHead with
658− | None -> dispatch (PhotoPicked None)
659− | Some file ->
660− use! stream = file.OpenReadAsync()
661− use ms = new MemoryStream()
662− do! stream.CopyToAsync(ms)
663− use skBmp = SkiaSharp.SKBitmap.Decode(ms.ToArray())
664− let size = min skBmp.Width skBmp.Height
665− let x = (skBmp.Width - size) / 2
666− let y = (skBmp.Height - size) / 2
667− use cropped = new SkiaSharp.SKBitmap(size, size)
668−
669− skBmp.ExtractSubset(cropped, SkiaSharp.SKRectI(x, y, x + size, y + size))
670− |> ignore
671−
672− use img = SkiaSharp.SKImage.FromBitmap(cropped)
673− use data = img.Encode(SkiaSharp.SKEncodedImageFormat.Png, 90)
674− dispatch (PhotoPicked(Some(data.ToArray())))
675− | None -> dispatch (PhotoPicked None)
676− with _ ->
677− dispatch (PhotoPicked None)
678− }
679− :> System.Threading.Tasks.Task)
680− |> ignore)
703+ | CmdPickPhoto -> pickPhotoCmd
681704
682705 | CmdSaveProfile profile -> Cmd.ofEffect (fun _ -> Store.saveProfile profile)
683706
client/Platform/iOS/Info.plist+2
@@ -35,6 +35,8 @@
3535 </array>
3636 <key>XSAppIconAssets</key>
3737 <string>Assets.xcassets/AppIcon.appiconset</string>
38+ <key>NSPhotoLibraryUsageDescription</key>
39+ <string>Skrepka needs access to your photo library to set a profile photo.</string>
3840 <key>UIStatusBarHidden</key>
3941 <true/>
4042 <key>UIViewControllerBasedStatusBarAppearance</key>
client/Platform/iOS/PhotoPicker.fsnew file+50
@@ -0,0 +1,50 @@
1+namespace Skrepka.iOS
2+
3+open System
4+open System.Threading.Tasks
5+open Foundation
6+open UIKit
7+open PhotosUI
8+
9+type private PickerDelegate(tcs: TaskCompletionSource<byte[] option>) =
10+ inherit PHPickerViewControllerDelegate()
11+
12+ override _.DidFinishPicking(controller, results) =
13+ controller.DismissViewController(true, null)
14+
15+ if results.Length = 0 then
16+ tcs.TrySetResult None |> ignore
17+ else
18+ results[0].ItemProvider.LoadDataRepresentation(
19+ "public.image",
20+ Action<NSData, NSError>(fun data error ->
21+ if isNull error && not (isNull data) then
22+ tcs.TrySetResult(Some(data.ToArray())) |> ignore
23+ else
24+ tcs.TrySetResult None |> ignore))
25+ |> ignore
26+
27+module PhotoPicker =
28+ let mutable private prevent_gc: obj = null
29+
30+ let pick () : Task<byte[] option> =
31+ let tcs = TaskCompletionSource<byte[] option>()
32+
33+ Avalonia.Threading.Dispatcher.UIThread.Post(fun () ->
34+ let config = new PHPickerConfiguration()
35+ config.Filter <- PHPickerFilter.ImagesFilter
36+ config.SelectionLimit <- 1n
37+
38+ let picker = new PHPickerViewController(config)
39+ let d = new PickerDelegate(tcs)
40+ prevent_gc <- box (picker, d)
41+ tcs.Task.ContinueWith(fun (_: Task<byte[] option>) -> prevent_gc <- null) |> ignore
42+ picker.Delegate <- d
43+
44+ let window = UIApplication.SharedApplication.KeyWindow
45+ if not (isNull window) && not (isNull window.RootViewController) then
46+ window.RootViewController.PresentViewController(picker, true, null)
47+ else
48+ tcs.TrySetResult None |> ignore)
49+
50+ tcs.Task
client/Skrepka.fsproj+1
@@ -33,6 +33,7 @@
3333 <Compile Include="Phonemic.fs" />
3434 <Compile Include="Store.fs" />
3535 <Compile Include="Buttons.fs" />
36+ <Compile Include="$(iOSPlatformFolder)PhotoPicker.fs" Condition="$(AvaloniaPlatform) == 'iOS'" />
3637 <Compile Include="App.fs" />
3738 </ItemGroup>
3839