Apple already ships the AR viewer your users know from Messages and Safari. Here is why presenting it beats rebuilding it, and the asset trap everyone hits…
A customer is looking at a chair on your product page and wants to know whether
it fits. The honest answer involves their room, not your photography.
iOS has shipped the answer since iOS 12. AR Quick Look is the viewer that opens
when you tap a USDZ file in Messages, Mail or Safari: it finds the floor, places
the object at real-world scale, lets the user walk around it, and draws a
contact shadow underneath so the thing looks like it is actually there.
The interesting question is not how to build that. It is why you would.
Sit down to build in-app AR properly and the list gets long quickly. Plane
detection. Placing the object without it sinking through the floor or floating
above it. Scaling gestures that do not fight the placement gesture. Occlusion,
so the object goes behind a real table leg instead of painting over it. Contact
shadows. The object/AR toggle. Sharing. Lighting that roughly matches the room.
Apple has tuned every one of those, on hardware they control, for years. More
importantly, your users have already learned it. The gestures, the toggle in
the corner, the way you tap to place — they know that interface from opening a
model someone sent them in Messages. A viewer you build yourself is a viewer
they have to learn, and it will be worse.
So the sensible move on iOS is to present Apple's and get out of the way:
await ArQuickLook.present('/path/to/chair.usdz');The future completes when the user closes the viewer, so await covers the
whole interaction rather than just the opening of it. That matters more than it
sounds — it means "show the model, then do the next thing" is a straight line
with no callback plumbing:
await ArQuickLook.present(path);await showRatingPrompt();
That is the whole reason ar_quick_look exists. It
is deliberately not an AR framework.
Here is the part that catches everyone, and it is worth understanding rather
than working around.
Quick Look takes a file path. It is a system viewer; it opens things on
disk. A Flutter asset is not on disk in any sense Quick Look can use — it lives
inside the app bundle behind Flutter's asset system, reachable through
rootBundle and nothing else. So this looks reasonable and cannot work:
await ArQuickLook.present('assets/models/chair.usdz'); // no such fileThe fix is mechanical: read the asset bytes, write them to a real file in a
temporary directory, hand over that path. Everybody who ships a bundled model
writes that function, so the package writes it for you:
await ArQuickLook.presentAsset('assets/models/chair.usdz');The copy is cached, so showing the same asset twice writes it once. And
materializeAsset is public for when something else needs a real path —
sharing a model, or handing it to another app — because the moment you have
solved "asset to file" for AR you will want it again elsewhere.
There are two things to ask before offering AR at all, and collapsing them into
one is a bug:
if (!ArQuickLook.isSupported) return; // iOS onlyif (!await ArQuickLook.canPreview(path)) return; // this file specifically
isSupported is about the platform. canPreview is about **the file in your
hand** — and it asks Quick Look rather than looking at the extension. That
distinction has teeth: a text file renamed .usdz comes back false, which is
exactly what you want before presenting a viewer that would otherwise open and
fail in front of the user.
If your models are downloaded rather than bundled, canPreview is the check
that catches a truncated download, an HTML error page saved with the wrong name,
or a format Quick Look does not handle on that OS version. Extension-sniffing
catches none of those.
Quick Look lets the user pinch to resize the object. For a decorative model that
is a nice touch. For furniture it destroys the entire purpose:
await ArQuickLook.present(path, allowsContentScaling: false);
A sofa the customer can shrink to half-size is no longer telling them whether it
fits through the door. If the reason the user opened AR was to judge dimensions,
scaling is not a feature, it is a way to get the wrong answer confidently.
Leave it on for a sculpture. Turn it off for anything with a measurement.
Failures here are not interchangeable, and a bare PlatformException with a
string message forces you to parse English to find out what happened.
ar_quick_look throws a sealed hierarchy instead —
NotOnThisPlatformException, FileNotFoundException,
UnsupportedFileException, NoHostException — so a switch over them is
exhaustive and the compiler tells you when you have missed one:
try { await ArQuickLook.present(path);} on FileNotFoundException { await redownload();} on UnsupportedFileException { showMessage('That model could not be opened.');}Each of those wants a genuinely different response. A missing file is
recoverable — fetch it again. An unsupported file is not, and the user should be
told plainly rather than watching a retry loop.
If you have variants — the same chair in four fabrics — you do not want four
separate viewer launches:
await ArQuickLook.presentAll(paths, initialIndex: 2);
Quick Look handles the paging itself, so the user swipes between finishes inside
the AR session instead of backing out to your list and starting placement over
each time. Given that placement is the slowest part of the interaction, that is
the difference between comparing three options and comparing one.
There is a pairing here worth pointing at, because the two halves fit together
with no glue.
roomplan wraps Apple's RoomPlan: the user sweeps their
phone around a room and gets back a structured model of it — walls, doors,
windows, furniture — and a USDZ export. ar_quick_look takes a USDZ path. So:
controller.rooms.listen((room) async { if (room.usdzPath != null) await ArQuickLook.present(room.usdzPath!);});Scan a room, then place the scan of that room back in front of you at table
scale. For anything involving space — surveying, moving, fit-out, insurance —
that loop is most of the product, and it is a dozen lines.
The AR camera experience needs a real device. A Simulator has no camera and no
world tracking, so there is a hard limit on what can be tested automatically,
and it would be dishonest to imply otherwise.
What runs on a Simulator and is tested there: the plugin registers,
isSupported returns true on iOS, a missing file and an unreadable one each
raise their own exception, and a text file renamed .usdz is correctly refused.
That covers the API contract and every failure path. The part it does not cover
is the AR session itself — placement, tracking, occlusion — which is Apple's
code rather than the plugin's, and which you should look at on a physical device
before shipping regardless of which package you use.
There is no Android support here, and that is not a to-do item.
AR Quick Look is an iOS system viewer. Android's rough equivalent is Scene
Viewer, which takes glTF/GLB rather than USDZ, has different capabilities, and
is reached through an intent. Wrapping both behind one API would mean either
lying about the differences or exposing them anyway through a union type — and
the model format alone is a genuine fork in your asset pipeline, not an
implementation detail a package can hide.
So this does one thing on one platform. isSupported tells you where you are,
and you branch. That is a smaller promise, and it is one the package can keep.
know it, and it handles placement, occlusion and shadows better than you will.
isSupported for the platform, canPreview for the specific file. canPreview asks Quick Look rather than trusting the
extension.
because they need different responses.
roomplan: scan a room, then putit back in front of you.
ar_quick_look is on pub.dev — MIT, no dependencies,
160/160 pub points, iOS.