Region Geofencing
Getting Started
Section titled “Getting Started”| GitHub | |
| Downloads | |
| Downloads (DocumentDb) |
Platform geofencing is cheap on battery but capped (20 regions on iOS, 60 on
Android) and circles only. Region geofencing removes both limits: it runs background GPS through
Shiny.Gps and, for each reading that gets past the distance/time filters, asks a region
source which region the device is in. Each region set (states, cities, delivery zones) keeps track of its own
current region, so a device can be in one region of every set at once. Enter and exit events go to your delegate.
| Platform Geofencing | Region Geofencing | |
|---|---|---|
| Max regions | 20 (iOS) / 60 (Android) | Unlimited |
| Region shapes | Circles | Polygons (with holes), points by proximity, or address levels |
| Data source | Registered one by one | A region source - SQLite documents, the geocoder, or your own |
| Detection | OS callbacks | Background GPS + a query per reading |
| Battery | Very efficient | Tunable with the reading filters |
| Package | What it contains |
|---|---|
Shiny.Geofencing.Regions |
The monitor, the IRegionSource contract, and the built-in geocoder source |
Shiny.Geofencing.Regions.DocumentDb |
The Shiny.DocumentDb SQLite source, plus a seeder for US states, Canadian provinces, and US & Canadian cities |
-
Install the packages
Terminal window dotnet add package Shiny.Geofencing.Regionsdotnet add package Shiny.Geofencing.Regions.DocumentDb # only for document regions -
Add the background GPS permissions. They are the same as GPS in realtime background mode: location always usage strings and the
locationbackground mode on iOS, and fine, background and foreground-service location permissions on Android. -
Implement the delegate. It is a singleton and runs on a background thread, including while the app is backgrounded.
using Shiny.Locations;public class MyRegionDelegate : IRegionGeofenceDelegate{public Task OnRegionChanged(RegionChange change){// change.Region is the region object itself - the document, or the Placemark for geocoder setsConsole.WriteLine($"{(change.Entered ? "Entered" : "Exited")} {change.RegionSet}: {change.RegionName}");return Task.CompletedTask;}} -
Register it in
MauiProgram.csbuilder.Services.AddRegionGeofencing<MyRegionDelegate>(cfg =>{cfg.MinimumDistance = Distance.FromMeters(300); // defaultcfg.MinimumTime = TimeSpan.FromMinutes(1); // defaultcfg.AddGeocoderRegionSet("cities", GeocoderRegionLevel.Locality);}); -
Start monitoring
// inject IRegionGeofenceManagervar access = await regions.RequestAccess();if (access == AccessState.Available)await regions.Start();
Start() is idempotent, and monitoring survives app restarts. After the OS relaunches the app, the listener is restored
from the last known region in each set, so entries are not raised again. Stop() also forgets the current regions.
GetCurrent() resolves the device’s current region in every set from the last GPS reading. It raises no events and
changes no state.
Reading Filters
Section titled “Reading Filters”Each GPS reading is filtered before any region query runs, which is what keeps the processing cost down:
| Property | Default | Meaning |
|---|---|---|
MinimumDistance |
300m | Readings closer than this to the last evaluated one are skipped |
MinimumTime |
1 minute | Readings sooner than this after the last evaluated one are skipped |
MaximumDistance |
unset | Safety net - always evaluate once the device has moved this far |
MaximumTime |
unset | Safety net - always evaluate once this much time has passed |
GpsRequest |
Realtime background | The listener configuration |
Document Regions (SQLite)
Section titled “Document Regions (SQLite)”Shiny.Geofencing.Regions.DocumentDb monitors any document type with a mapped geometry in a
Shiny.DocumentDb SQLite store. Polygons are matched by containment. Points can only match by
proximity, because a GPS reading never falls inside a point, so give them a radius with withinMeters. When
regions overlap, the nearest one wins.
builder.Services.AddDocumentStore(opts =>{ opts.DatabaseProvider = new SqliteDatabaseProvider($"Data Source={dbPath}"); opts.ConfigureDocument<Zone>(cfg => cfg.MapSpatialProperty(nameof(Zone.Boundary), z => z.Boundary)); opts.ConfigureDocument<Store>(cfg => cfg.MapSpatialProperty(nameof(Store.Location), s => s.Location));});
builder.Services.AddRegionGeofencing<MyRegionDelegate>(cfg => cfg .AddDocumentRegionSet<Zone>("zones", z => z.Id, z => z.Name, filter: z => z.Active) .AddDocumentRegionSet<Store>("stores", s => s.Id, s => s.Name, withinMeters: 500));idSelectormust return the id the store keys the document by. When an exit comes after an app restart, the region is read back by that id so the event can still describe it.filternarrows which documents are monitored.storeNameselects a keyed store instead of the default one.
US & Canadian Regions
Section titled “US & Canadian Regions”The package ships a small embedded dataset of US states, Canadian provinces, and US & Canadian cities. Seed it into the store and monitor it like any other document set:
builder.Services.AddDocumentStore(opts =>{ opts.DatabaseProvider = new SqliteDatabaseProvider($"Data Source={dbPath}"); opts.MapNorthAmericaData(); // maps GeoRegion.Boundary and GeoCity.Location});builder.Services.AddNorthAmericaSeeder(); // seeds once, idempotent
builder.Services.AddRegionGeofencing<MyRegionDelegate>(cfg => cfg .AddNorthAmericanRegions() // states & provinces by containment - set "regions" .AddNorthAmericanCities(withinMeters: 25_000, filter: c => c.Population >= 250_000) // set "cities");Region ids are {Country}-{Abbreviation} (US-CO, CA-ON), and city ids are {Country}-{Region}-{slug}
(US-CO-denver). The boundaries are deliberately coarse: good enough for “which state am I in”, not for cartography.
NorthAmericaData.Regions and NorthAmericaData.Cities expose the raw data if you want to read it directly.
Geocoder Regions
Section titled “Geocoder Regions”AddGeocoderRegionSet resolves regions by reverse geocoding each evaluated reading through
IGeocoder. There’s no data to seed, but every evaluated reading needs network
access. AddRegionGeofencing registers the geocoder for you if it isn’t already registered.
builder.Services.AddRegionGeofencing<MyRegionDelegate>(cfg => cfg .AddGeocoderRegionSet("countries", GeocoderRegionLevel.Country) .AddGeocoderRegionSet("states", GeocoderRegionLevel.AdministrativeArea) .AddGeocoderRegionSet("cities", GeocoderRegionLevel.Locality) .AddGeocoderRegionSet("postal", p => p.PostalCode) // or pick the region yourself);| Level | Region id | Name |
|---|---|---|
Country |
US |
Country name |
AdministrativeArea |
US-Colorado |
State / province |
SubAdministrativeArea |
US-Colorado-Denver County |
County |
Locality |
US-Colorado-Denver |
City / town |
SubLocality |
US-Colorado-Denver-LoDo |
Neighbourhood |
PostalCode |
US-80202 |
Postal code |
- The region object is the
Placemark(change.RegionAs<Placemark>()). - If any part of an id is missing (no locality out in the countryside, say), the device is outside that set.
- A failed lookup (no connection, a throttled request) leaves the set’s current region unchanged, so going offline never produces a false exit.
- All geocoder sets evaluating the same reading share one lookup, so three sets cost one request.
- A geocoder can’t look regions up by id. An exit that comes after an app restart carries only
RegionId, withRegionandRegionNamenull. - Geocoders are rate limited: Apple throttles heavy use, and Nominatim (the fallback) allows one request a second.
Keep
MinimumTimeat its default or higher.
Custom Region Sources
Section titled “Custom Region Sources”Any data can back a set. Implement IRegionSource and register it with AddRegionSet:
public class MyZoneSource(IMyZoneApi api) : IRegionSource{ public async Task<RegionMatch?> FindRegion(Position position, CancellationToken ct = default) { var zone = await api.FindZone(position.Latitude, position.Longitude, ct); return zone == null ? null : new RegionMatch(zone.Id, zone.Name, zone); }
public async Task<RegionMatch?> GetRegion(string regionId, CancellationToken ct = default) { var zone = await api.GetZone(regionId, ct); return zone == null ? null : new RegionMatch(zone.Id, zone.Name, zone); }}
cfg.AddRegionSet("zones", sp => new MyZoneSource(sp.GetRequiredService<IMyZoneApi>()));When FindRegion throws, the error is logged and that set keeps its current region. The other sets are still
evaluated.
Migrating from Shiny.DocumentDb.Geofencing
Section titled “Migrating from Shiny.DocumentDb.Geofencing”| Shiny.DocumentDb.Geofencing / .Geo | Shiny.Geofencing.Regions(.DocumentDb) |
|---|---|
AddDocumentGeofencing<T>(cfg => ...) |
AddRegionGeofencing<T>(cfg => ...) |
cfg.AddRegionSet<T>(...) |
cfg.AddDocumentRegionSet<T>(...) |
cfg.StoreName |
storeName: on each AddDocumentRegionSet |
IDocumentGeofenceManager / IDocumentGeofenceDelegate |
IRegionGeofenceManager / IRegionGeofenceDelegate |
DocumentRegionChange / DocumentCurrentRegion |
RegionChange / CurrentRegion (Position is a Shiny.Locations.Position) |
Shiny.DocumentDb.Geo - GeoDataSets, GeoReferenceSeeder |
Shiny.Locations.NorthAmerica - NorthAmericaData, NorthAmericaSeeder |
MapGeoReferenceData() / AddGeoReferenceSeeder() |
MapNorthAmericaData() / AddNorthAmericaSeeder() |
The current-region state is stored under new keys, so the first reading after upgrading raises fresh entries. The dataset seeder also runs once more under its new name. It upserts, so it doesn’t create duplicates.
Step 1 — Add the marketplace:
claude plugin marketplace add shinyorg/skillsStep 2 — Install the plugin:
claude plugin install shiny@shinyOne plugin installs all 48 Shiny skills. Your agent loads only the skill relevant to what you're building, so there's no cost to having them all available.
Step 1 — Add the marketplace:
copilot plugin marketplace add https://github.com/shinyorg/skillsStep 2 — Install the plugin:
copilot plugin install shiny@shinyOne plugin installs all 48 Shiny skills. Your agent loads only the skill relevant to what you're building, so there's no cost to having them all available.


