Skip to content

Geofencing

GPS-driven geofence monitoring for iOS and Android. Built on Shiny.Locations for background GPS and Shiny.Spatial for spatial queries.

  • GitHub stars for shinyorg/geospatialdb

Traditional platform geofencing is limited to 20 regions on iOS and 60 on Android. Spatial geofencing removes that limit entirely — point the monitor at one or more spatial database tables containing city, state, or province polygons and it detects region enter/exit automatically using the R*Tree index.

Platform GeofencingSpatial Geofencing
Max regions20 (iOS) / 60 (Android)Unlimited
Region shapesCircles onlyAny polygon (with holes)
Data sourceRegister individuallySpatial database tables
DetectionOS-level callbacksGPS + R*Tree spatial query
BatteryVery efficient (OS-managed)Configurable GPS intervals
  1. Install the NuGet package

    Terminal window
    dotnet add package Shiny.Spatial.Geofencing
  2. Implement the delegate

    public class MyGeofenceDelegate : ISpatialGeofenceDelegate
    {
    public Task OnRegionChanged(SpatialRegionChange change)
    {
    var name = change.Region.Properties.GetValueOrDefault("name") ?? "Unknown";
    var action = change.Entered ? "Entered" : "Exited";
    Console.WriteLine($"{action}: {name}");
    return Task.CompletedTask;
    }
    }
  3. Register in MauiProgram.cs

    builder.Services.AddSpatialGps<MyGeofenceDelegate>(config =>
    {
    config.MinimumDistance = Distance.FromMeters(300);
    config.MinimumTime = TimeSpan.FromMinutes(1);
    config
    .Add(CopyAssetToAppData("us-states.db"), "states")
    .Add(CopyAssetToAppData("us-cities.db"), "cities");
    });
  4. Start monitoring

    // Inject ISpatialGeofenceManager
    await geofences.RequestAccess();
    await geofences.Start();
FrameworkNotes
net10.0-iosiOS 15+ with background GPS
net10.0-androidAndroid 5+ with background GPS

Add() requires a file path on disk. For databases bundled as MAUI raw assets (Resources/Raw), copy the file to AppDataDirectory first — SQLite cannot open files directly from the app package.

builder.Services.AddSpatialGps<MyGeofenceDelegate>(config => config
.Add(CopyAssetToAppData("ca-cities.db"), "cities")
.Add(CopyAssetToAppData("us-states.db"), "states")
);
static string CopyAssetToAppData(string assetFileName)
{
var destPath = Path.Combine(FileSystem.AppDataDirectory, assetFileName);
if (!File.Exists(destPath))
{
using var source = FileSystem.OpenAppPackageFileAsync(assetFileName)
.GetAwaiter().GetResult();
using var dest = File.Create(destPath);
source.CopyTo(dest);
}
return destPath;
}

The main interface for controlling geofence monitoring. Inject it into your pages or view models.

public interface ISpatialGeofenceManager
{
bool IsStarted { get; }
Task<AccessState> RequestAccess();
Task Start();
Task Stop();
Task<IReadOnlyList<SpatialCurrentRegion>> GetCurrent(CancellationToken cancelToken = default);
}
MethodDescription
IsStartedWhether geofence monitoring is active
RequestAccess()Requests GPS permissions from the user
Start()Begins background GPS monitoring and region detection
Stop()Stops monitoring
GetCurrent()Gets the current GPS position and queries all monitored tables to determine which region(s) the device is in

Implement this interface to receive geofence enter/exit events.

public interface ISpatialGeofenceDelegate
{
Task OnRegionChanged(SpatialRegionChange change);
}

Event data for geofence transitions. Each event represents entering or exiting a single region.

public record SpatialRegionChange(
string TableName,
SpatialFeature Region,
bool Entered
);
PropertyDescription
TableNameThe spatial table that was matched
RegionThe SpatialFeature being entered or exited
Enteredtrue for entry, false for exit

Returned by ISpatialGeofenceManager.GetCurrent(). One entry per monitored table.

public record SpatialCurrentRegion(string TableName, SpatialFeature? Region);

Region is null when the device is not inside any feature in that table.

Configuration for which databases and tables to monitor.

public class SpatialMonitorConfig
{
public List<SpatialMonitorEntry> Entries { get; }
public Distance? MinimumDistance { get; set; } // default: 300m
public TimeSpan? MinimumTime { get; set; } // default: 1 minute
public SpatialMonitorConfig Add(string databasePath, string tableName);
}
PropertyDefaultDescription
MinimumDistance300 metersMinimum distance the device must move before a new GPS reading is processed
MinimumTime1 minuteMinimum time between GPS readings
EntriesEmptyList of database/table pairs to monitor
public class MyGeofenceDelegate(
ILogger<MyGeofenceDelegate> logger,
INotificationManager notifications
) : ISpatialGeofenceDelegate
{
public async Task OnRegionChanged(SpatialRegionChange change)
{
var regionName = change.Region.Properties.GetValueOrDefault("name") ?? "Unknown";
var action = change.Entered ? "Entered" : "Exited";
logger.LogInformation("{Action} {Region} in {Table}", action, regionName, change.TableName);
await notifications.Send("Geofence", $"{action}: {regionName}");
}
}
public class GeofenceViewModel(ISpatialGeofenceManager geofences)
{
public bool IsMonitoring => geofences.IsStarted;
public async Task ToggleMonitoring()
{
if (geofences.IsStarted)
{
await geofences.Stop();
}
else
{
await geofences.RequestAccess();
await geofences.Start();
}
}
public async Task CheckCurrentRegions()
{
var regions = await geofences.GetCurrent();
foreach (var r in regions)
{
var name = r.Region?.Properties.GetValueOrDefault("name") ?? "None";
Console.WriteLine($"{r.TableName}: {name}");
}
}
}

Monitor both state and city boundaries simultaneously:

builder.Services.AddSpatialGps<MyGeofenceDelegate>(config => config
.Add(CopyAssetToAppData("us-states.db"), "states")
.Add(CopyAssetToAppData("us-cities.db"), "cities")
);

Your delegate receives separate events for each table. For example, driving into Denver produces:

  • TableName = "states", Region = Colorado, Entered = true
  • TableName = "cities", Region = Denver, Entered = true

Under the hood, SpatialGpsDelegate listens to GPS readings via Shiny.Locations and runs a spatial intersection query against each monitored table on every reading:

  1. A GPS reading arrives with the device’s current latitude/longitude
  2. For each monitored table, the delegate queries table.Query().Intersecting(point).FirstOrDefault()
  3. If the matched region differs from the previous reading, enter/exit events fire
  4. The delegate compares by feature ID, so moving within the same region produces no events

The spatial query uses the two-pass pipeline — the R*Tree index eliminates 99%+ of candidates before any geometry computation, keeping each GPS reading efficient even against large databases.