diff --git a/CHANGELOG.md b/CHANGELOG.md
index 566fc8355..6f856883d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,6 @@
+## [55.3.0]
+- [PictureInPicture] Added `PipService` with `Enter()`, `Enter(ratioWidth, ratioHeight)`, `IsSupported`, and `PipModeChanged` event for Android and iOS. On iOS 15+, uses `AVPictureInPictureController` with `AVSampleBufferDisplayLayer` to display a snapshot of the current window. On Android, uses `EnterPictureInPictureMode`. Includes a sample in the Components app demonstrating usage.
+
## [55.2.2]
- [iOS26][Tip] Added more padding.
diff --git a/src/app/Components/ComponentsSamples/PictureInPicture/PictureInPictureSamples.xaml b/src/app/Components/ComponentsSamples/PictureInPicture/PictureInPictureSamples.xaml
new file mode 100644
index 000000000..646b3d651
--- /dev/null
+++ b/src/app/Components/ComponentsSamples/PictureInPicture/PictureInPictureSamples.xaml
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/app/Components/ComponentsSamples/PictureInPicture/PictureInPictureSamples.xaml.cs b/src/app/Components/ComponentsSamples/PictureInPicture/PictureInPictureSamples.xaml.cs
new file mode 100644
index 000000000..22abe9cb0
--- /dev/null
+++ b/src/app/Components/ComponentsSamples/PictureInPicture/PictureInPictureSamples.xaml.cs
@@ -0,0 +1,18 @@
+namespace Components.ComponentsSamples.PictureInPicture;
+
+public partial class PictureInPictureSamples
+{
+ public PictureInPictureSamples()
+ {
+ InitializeComponent();
+ }
+
+ protected override void OnDisappearing()
+ {
+ base.OnDisappearing();
+ if (BindingContext is PictureInPictureSamplesViewModel vm)
+ {
+ vm.Unsubscribe();
+ }
+ }
+}
diff --git a/src/app/Components/ComponentsSamples/PictureInPicture/PictureInPictureSamplesViewModel.cs b/src/app/Components/ComponentsSamples/PictureInPicture/PictureInPictureSamplesViewModel.cs
new file mode 100644
index 000000000..48c7888ab
--- /dev/null
+++ b/src/app/Components/ComponentsSamples/PictureInPicture/PictureInPictureSamplesViewModel.cs
@@ -0,0 +1,56 @@
+using System.Windows.Input;
+using DIPS.Mobile.UI.API.PictureInPicture;
+using DIPS.Mobile.UI.MVVM;
+
+namespace Components.ComponentsSamples.PictureInPicture;
+
+public class PictureInPictureSamplesViewModel : ViewModel
+{
+ private string m_notes = string.Empty;
+ private string m_statusText = string.Empty;
+
+ public PictureInPictureSamplesViewModel()
+ {
+ EnterPipCommand = new Command(EnterPip);
+ PipService.PipModeChanged += OnPipModeChanged;
+ }
+
+ private void EnterPip()
+ {
+ PipService.Enter();
+ }
+
+ private void OnPipModeChanged(object? sender, bool isInPipMode)
+ {
+ StatusText = isInPipMode
+ ? "App is in Picture in Picture mode."
+ : "App returned from Picture in Picture mode.";
+ }
+
+ public void Unsubscribe()
+ {
+ PipService.PipModeChanged -= OnPipModeChanged;
+ }
+
+ public bool IsPipSupported => PipService.IsSupported;
+
+ public string Notes
+ {
+ get => m_notes;
+ set => RaiseWhenSet(ref m_notes, value);
+ }
+
+ public string StatusText
+ {
+ get => m_statusText;
+ set
+ {
+ RaiseWhenSet(ref m_statusText, value);
+ RaisePropertyChanged(nameof(HasStatus));
+ }
+ }
+
+ public bool HasStatus => !string.IsNullOrEmpty(StatusText);
+
+ public ICommand EnterPipCommand { get; }
+}
diff --git a/src/app/Components/Platforms/Android/MainActivity.cs b/src/app/Components/Platforms/Android/MainActivity.cs
index 3e16d5b75..0852acb87 100644
--- a/src/app/Components/Platforms/Android/MainActivity.cs
+++ b/src/app/Components/Platforms/Android/MainActivity.cs
@@ -1,13 +1,23 @@
using Android.App;
+using Android.Content;
using Android.Content.PM;
using Android.OS;
using AndroidX.Activity;
using AndroidX.Core.View;
+using DIPS.Mobile.UI.API.PictureInPicture;
namespace Components;
[Activity(Theme = "@style/DIPS.Mobile.UI.Style", MainLauncher = true,
ScreenOrientation = ScreenOrientation.Portrait,
+ SupportsPictureInPicture = true,
ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode |
ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]
-public class MainActivity : MauiAppCompatActivity;
\ No newline at end of file
+public class MainActivity : MauiAppCompatActivity
+{
+ public override void OnPictureInPictureModeChanged(bool isInPictureInPictureMode, Configuration newConfig)
+ {
+ base.OnPictureInPictureModeChanged(isInPictureInPictureMode, newConfig);
+ PipService.NotifyPipModeChanged(isInPictureInPictureMode);
+ }
+}
\ No newline at end of file
diff --git a/src/app/Components/REGISTER_YOUR_SAMPLES_HERE.cs b/src/app/Components/REGISTER_YOUR_SAMPLES_HERE.cs
index 0a0ec44ee..d14c70507 100644
--- a/src/app/Components/REGISTER_YOUR_SAMPLES_HERE.cs
+++ b/src/app/Components/REGISTER_YOUR_SAMPLES_HERE.cs
@@ -1,6 +1,7 @@
using Components.AccessibilitySamples;
using Components.AccessibilitySamples.VoiceOverSamples;
using Components.ComponentsSamples.Alerting;
+using Components.ComponentsSamples.PictureInPicture;
using Components.ComponentsSamples.AmplitudeView;
using Components.ComponentsSamples.BarcodeScanning;
using Components.ComponentsSamples.BottomSheets;
@@ -73,6 +74,7 @@ public static List RegisterSamples()
new(SampleType.Components, "Zoom Container", () => new PanZoomContainerSample()),
new(SampleType.Components, "Gallery", () => new GallerySample()),
new(SampleType.Components, "TIFF Viewer", () => new TiffViewerSample()),
+ new(SampleType.Components, "Picture in Picture", () => new PictureInPictureSamples()),
new(SampleType.Accessibility, "VoiceOver/TalkBack", () => new VoiceOverSamples()),
diff --git a/src/library/DIPS.Mobile.UI/API/PictureInPicture/Android/PipService.cs b/src/library/DIPS.Mobile.UI/API/PictureInPicture/Android/PipService.cs
new file mode 100644
index 000000000..cb143a52c
--- /dev/null
+++ b/src/library/DIPS.Mobile.UI/API/PictureInPicture/Android/PipService.cs
@@ -0,0 +1,43 @@
+using Android.Content.PM;
+using Android.OS;
+using Android.Util;
+
+namespace DIPS.Mobile.UI.API.PictureInPicture;
+
+public static partial class PipService
+{
+ public static partial bool IsSupported
+ {
+ get
+ {
+ if (Build.VERSION.SdkInt < BuildVersionCodes.O)
+ return false;
+
+ return Platform.AppContext.PackageManager
+ ?.HasSystemFeature(PackageManager.FeaturePictureInPicture) == true;
+ }
+ }
+
+ public static partial void Enter() => Enter(9, 16);
+
+ public static partial void Enter(int ratioWidth, int ratioHeight)
+ {
+ if (!IsSupported)
+ return;
+
+ var activity = Platform.CurrentActivity;
+ if (activity is null)
+ return;
+
+ var paramsBuilder = new Android.App.PictureInPictureParams.Builder();
+
+ if (Build.VERSION.SdkInt >= BuildVersionCodes.S)
+ {
+ paramsBuilder.SetSeamlessResizeEnabled(true);
+ }
+
+ paramsBuilder.SetAspectRatio(new Rational(ratioWidth, ratioHeight));
+
+ activity.EnterPictureInPictureMode(paramsBuilder.Build());
+ }
+}
diff --git a/src/library/DIPS.Mobile.UI/API/PictureInPicture/PipService.cs b/src/library/DIPS.Mobile.UI/API/PictureInPicture/PipService.cs
new file mode 100644
index 000000000..bec226824
--- /dev/null
+++ b/src/library/DIPS.Mobile.UI/API/PictureInPicture/PipService.cs
@@ -0,0 +1,57 @@
+namespace DIPS.Mobile.UI.API.PictureInPicture;
+
+///
+/// Service for entering Picture in Picture (PiP) mode, allowing the app to be displayed
+/// in a small floating window while the user interacts with other apps or navigates away.
+///
+public static partial class PipService
+{
+ ///
+ /// Gets a value indicating whether Picture in Picture mode is supported on this device.
+ ///
+ public static partial bool IsSupported { get; }
+
+ ///
+ /// Enters Picture in Picture mode. The app will be displayed in a small floating window
+ /// with a default 9:16 (portrait) aspect ratio.
+ ///
+ ///
+ /// On Android, the activity must have android:supportsPictureInPicture="true" in the manifest,
+ /// or SupportsPictureInPicture = true in the [Activity] attribute.
+ /// On iOS 15+, this captures a snapshot of the current window and displays it in a PiP window using
+ /// AVPictureInPictureController with AVSampleBufferDisplayLayer.
+ ///
+ public static partial void Enter();
+
+ ///
+ /// Enters Picture in Picture mode with a specific aspect ratio for the PiP window.
+ ///
+ /// The width component of the desired aspect ratio.
+ /// The height component of the desired aspect ratio.
+ ///
+ /// On Android, the activity must have android:supportsPictureInPicture="true" in the manifest,
+ /// or SupportsPictureInPicture = true in the [Activity] attribute.
+ /// On iOS 15+, this captures a snapshot of the current window and displays it in a PiP window using
+ /// AVPictureInPictureController with AVSampleBufferDisplayLayer.
+ ///
+ public static partial void Enter(int ratioWidth, int ratioHeight);
+
+ ///
+ /// Raised when the app enters or exits Picture in Picture mode.
+ ///
+ ///
+ /// On Android, override OnPictureInPictureModeChanged in your MainActivity and call
+ /// for this event to fire.
+ ///
+ public static event EventHandler? PipModeChanged;
+
+ ///
+ /// Notifies the service that the PiP mode has changed.
+ /// Call this from your MainActivity.OnPictureInPictureModeChanged override on Android.
+ ///
+ /// true if the app entered PiP mode; false if it exited.
+ public static void NotifyPipModeChanged(bool isInPipMode)
+ {
+ PipModeChanged?.Invoke(null, isInPipMode);
+ }
+}
diff --git a/src/library/DIPS.Mobile.UI/API/PictureInPicture/dotnet/PipService.cs b/src/library/DIPS.Mobile.UI/API/PictureInPicture/dotnet/PipService.cs
new file mode 100644
index 000000000..e75124df5
--- /dev/null
+++ b/src/library/DIPS.Mobile.UI/API/PictureInPicture/dotnet/PipService.cs
@@ -0,0 +1,12 @@
+using DIPS.Mobile.UI.Exceptions;
+
+namespace DIPS.Mobile.UI.API.PictureInPicture;
+
+public static partial class PipService
+{
+ public static partial bool IsSupported => throw new Only_Here_For_UnitTests();
+
+ public static partial void Enter() => throw new Only_Here_For_UnitTests();
+
+ public static partial void Enter(int ratioWidth, int ratioHeight) => throw new Only_Here_For_UnitTests();
+}
diff --git a/src/library/DIPS.Mobile.UI/API/PictureInPicture/iOS/PipService.cs b/src/library/DIPS.Mobile.UI/API/PictureInPicture/iOS/PipService.cs
new file mode 100644
index 000000000..bfb77ba83
--- /dev/null
+++ b/src/library/DIPS.Mobile.UI/API/PictureInPicture/iOS/PipService.cs
@@ -0,0 +1,218 @@
+using AVFoundation;
+using AVKit;
+using CoreGraphics;
+using CoreMedia;
+using CoreVideo;
+using Foundation;
+using UIKit;
+
+namespace DIPS.Mobile.UI.API.PictureInPicture;
+
+public static partial class PipService
+{
+ private static AVPictureInPictureController? s_pipController;
+ private static PipSampleBufferView? s_pipView;
+ private static PipPlaybackDelegate? s_playbackDelegate;
+ private static PipControllerDelegate? s_controllerDelegate;
+
+ private static readonly CGColorSpace s_rgbColorSpace = CGColorSpace.CreateDeviceRGB();
+
+ public static partial bool IsSupported =>
+ AVPictureInPictureController.IsPictureInPictureSupported();
+
+ public static partial void Enter() => Enter(9, 16);
+
+ public static partial void Enter(int ratioWidth, int ratioHeight)
+ {
+ if (!IsSupported)
+ return;
+
+ var window = GetKeyWindow();
+ if (window is null)
+ return;
+
+ var windowWidth = window.Bounds.Width;
+ var windowHeight = windowWidth * ratioHeight / ratioWidth;
+ var frameSize = new CGSize(windowWidth, windowHeight);
+
+ s_pipView?.Dispose();
+ s_pipView = new PipSampleBufferView(frameSize);
+
+ s_playbackDelegate?.Dispose();
+ s_playbackDelegate = new PipPlaybackDelegate(window, frameSize, s_pipView.SampleBufferDisplayLayer);
+
+ s_controllerDelegate?.Dispose();
+ s_controllerDelegate = new PipControllerDelegate();
+
+ var contentSource = new AVPictureInPictureControllerContentSource(
+ s_pipView.SampleBufferDisplayLayer,
+ s_playbackDelegate);
+
+ s_pipController?.Dispose();
+ s_pipController = new AVPictureInPictureController(contentSource)
+ {
+ Delegate = s_controllerDelegate
+ };
+
+ s_playbackDelegate.PushCurrentFrame();
+
+ if (s_pipController.PictureInPicturePossible)
+ {
+ s_pipController.StartPictureInPicture();
+ }
+ }
+
+ private static UIWindow? GetKeyWindow()
+ {
+ foreach (var scene in UIApplication.SharedApplication.ConnectedScenes)
+ {
+ if (scene is UIWindowScene windowScene &&
+ windowScene.ActivationState == UISceneActivationState.ForegroundActive)
+ {
+ return windowScene.Windows.FirstOrDefault(w => w.IsKeyWindow);
+ }
+ }
+ return null;
+ }
+
+ private sealed class PipSampleBufferView : UIView
+ {
+ public AVSampleBufferDisplayLayer SampleBufferDisplayLayer { get; }
+
+ public PipSampleBufferView(CGSize size) : base(new CGRect(CGPoint.Empty, size))
+ {
+ SampleBufferDisplayLayer = new AVSampleBufferDisplayLayer();
+ SampleBufferDisplayLayer.Frame = Bounds;
+ Layer.AddSublayer(SampleBufferDisplayLayer);
+ }
+ }
+
+ [Register("PipPlaybackDelegate")]
+ private sealed class PipPlaybackDelegate : NSObject, IAVPictureInPictureSampleBufferPlaybackDelegate
+ {
+ private readonly UIView m_sourceView;
+ private readonly CGSize m_frameSize;
+ private readonly AVSampleBufferDisplayLayer m_displayLayer;
+
+ public PipPlaybackDelegate(UIView sourceView, CGSize frameSize, AVSampleBufferDisplayLayer displayLayer)
+ {
+ m_sourceView = sourceView;
+ m_frameSize = frameSize;
+ m_displayLayer = displayLayer;
+ }
+
+ public void PushCurrentFrame()
+ {
+ var sampleBuffer = CreateSampleBuffer();
+ if (sampleBuffer is not null)
+ {
+ m_displayLayer.Enqueue(sampleBuffer);
+ sampleBuffer.Dispose();
+ }
+ }
+
+ ///
+ /// Static snapshot display — play/pause controls are not applicable.
+ ///
+ [Export("pictureInPictureController:setPlaying:")]
+ public void SetPlaying(AVPictureInPictureController pictureInPictureController, bool playing) { }
+
+ ///
+ /// Static snapshot display is always "paused" from PiP's perspective.
+ ///
+ [Export("pictureInPictureControllerIsPlaybackPaused:")]
+ public bool IsPlaybackPaused(AVPictureInPictureController pictureInPictureController) => true;
+
+ ///
+ /// Static snapshot display has no seekable time range — skip is a no-op.
+ ///
+ [Export("pictureInPictureController:skipByInterval:completionHandler:")]
+ public void SkipByInterval(AVPictureInPictureController pictureInPictureController, CMTime skipInterval,
+ Action completionHandler)
+ {
+ completionHandler();
+ }
+
+ ///
+ /// Returns an indefinite time range so the PiP controller does not show a playback position.
+ ///
+ [Export("pictureInPictureControllerTimeRangeForPlayback:")]
+ public CMTimeRange GetTimeRange(AVPictureInPictureController pictureInPictureController) =>
+ new CMTimeRange { Start = CMTime.Zero, Duration = CMTime.Indefinite };
+
+ [Export("pictureInPictureController:didTransitionToRenderSize:")]
+ public void DidTransitionToRenderSize(AVPictureInPictureController pictureInPictureController,
+ CMVideoDimensions newRenderSize) { }
+
+ private CMSampleBuffer? CreateSampleBuffer()
+ {
+ var width = (nint)m_frameSize.Width;
+ var height = (nint)m_frameSize.Height;
+
+ if (width <= 0 || height <= 0)
+ return null;
+
+ var pixelBufferAttrs = new CVPixelBufferAttributes
+ {
+ CGImageCompatibility = true,
+ CGBitmapContextCompatibility = true
+ };
+
+ var pixelBuffer = new CVPixelBuffer(width, height, CVPixelFormatType.CV32BGRA, pixelBufferAttrs);
+ pixelBuffer.Lock(CVOptionFlags.None);
+
+ try
+ {
+ using var bitmapContext = new CGBitmapContext(
+ pixelBuffer.BaseAddress,
+ width, height,
+ 8,
+ pixelBuffer.BytesPerRow,
+ s_rgbColorSpace,
+ CGBitmapFlags.ByteOrder32Little | CGBitmapFlags.NoneSkipFirst);
+
+ bitmapContext.TranslateCTM(0, height);
+ bitmapContext.ScaleCTM(1, -1);
+ m_sourceView.Layer.RenderInContext(bitmapContext);
+ }
+ finally
+ {
+ pixelBuffer.Unlock(CVOptionFlags.None);
+ }
+
+ var formatDescription = CMVideoFormatDescription.CreateForImageBuffer(pixelBuffer);
+ if (formatDescription is null)
+ {
+ pixelBuffer.Dispose();
+ return null;
+ }
+
+ var timing = new CMSampleTimingInfo
+ {
+ Duration = CMTime.Invalid,
+ PresentationTimeStamp = CMTime.Zero,
+ DecodeTimeStamp = CMTime.Invalid
+ };
+
+ CMSampleBuffer.CreateReadyWithImageBuffer(pixelBuffer, formatDescription, timing, out var sampleBuffer);
+ formatDescription.Dispose();
+ pixelBuffer.Dispose();
+
+ return sampleBuffer;
+ }
+ }
+
+ [Register("PipControllerDelegate")]
+ private sealed class PipControllerDelegate : AVPictureInPictureControllerDelegate
+ {
+ public override void DidStartPictureInPicture(AVPictureInPictureController pictureInPictureController)
+ {
+ NotifyPipModeChanged(true);
+ }
+
+ public override void DidStopPictureInPicture(AVPictureInPictureController pictureInPictureController)
+ {
+ NotifyPipModeChanged(false);
+ }
+ }
+}