Skip to content
This repository was archived by the owner on Apr 10, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,14 @@ pickPlace({
enableUserLocation: true,
enableGeocoding: true,
color: "#FF00FF",
// Range selection (optional)
enableRangeSelection: true,
initialRadius: 2000,
minRadius: 250,
maxRadius: 10000,
radiusColor: '#FF00FF33',
radiusStrokeColor: '#FF00FF',
radiusStrokeWidth: 2,
//...etc
})
.then(console.log)
Expand Down Expand Up @@ -133,6 +141,13 @@ pickPlace().then(console.log).catch(console.log);
| `enableUserLocation` | `boolean` | current user position button. Requires setup. | `true` |
| `enableLargeTitle` | `boolean` | large navigation bar title of the UIViewController. **iOS only** | `true` |
| `rejectOnCancel` | `boolean` | Reject and return nothing if the user dismisses the window without selecting a place. | `true` |
| `enableRangeSelection` | `boolean` | Enable draggable radius selection overlay. | `false` |
| `initialRadius` | `number` | Initial radius in meters when range selection is enabled. | `1000` |
| `minRadius` | `number` | Minimum allowed radius in meters. | `100` |
| `maxRadius` | `number` | Maximum allowed radius in meters. | `10000` |
| `radiusColor` | `string` | Fill color of radius circle (falls back to `color` with alpha). | `''` |
| `radiusStrokeColor` | `string` | Stroke color of radius circle (falls back to `color`). | `''` |
| `radiusStrokeWidth` | `number` | Stroke width of radius circle in pixels. | `2` |

### PlacePickerPresentationStyle

Expand Down Expand Up @@ -166,6 +181,8 @@ pickPlace().then(console.log).catch(console.log);
| `coordinate` | `PlacePickerCoordinate` | Selected coordinate. |
| `address` | `PlacePickerAddress` | Geocoded address for selected location (if `enableGeocoding`). |
| `didCancel` | `boolean` | Indicates if the place picker was canceled without selecting. |
| `radius` | `number` | Selected radius in meters (if range selection enabled). |
| `radiusCoordinates` | `{ center, bounds }` | Center and bounds of the selected area. |

## Contributing

Expand Down
145 changes: 145 additions & 0 deletions android/src/main/java/expo/modules/placepicker/PlacePickerActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import com.google.android.gms.maps.GoogleMap
import com.google.android.gms.maps.OnMapReadyCallback
import com.google.android.gms.maps.SupportMapFragment
import com.google.android.gms.maps.model.LatLng
import com.google.android.gms.maps.model.Circle
import com.google.android.gms.maps.model.CircleOptions
import java.util.Locale
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledFuture
Expand All @@ -40,6 +42,10 @@ class PlacePickerActivity : AppCompatActivity(), OnMapReadyCallback,
private lateinit var pinViewAnimation: ObjectAnimator
private var mLocationProvider: FusedLocationProviderClient? = null
private lateinit var geocoder: Geocoder
private var radiusCircle: Circle? = null
private var currentRadius: Double = 1000.0
private var radiusHandle: View? = null
private var isDragging: Boolean = false

private fun getLocationProvider(): FusedLocationProviderClient {
if (mLocationProvider == null) {
Expand All @@ -64,6 +70,10 @@ class PlacePickerActivity : AppCompatActivity(), OnMapReadyCallback,
)
this.title = PlacePickerState.globalOptions.title
supportActionBar?.subtitle = ""
if (PlacePickerState.globalOptions.enableRangeSelection) {
currentRadius = PlacePickerState.globalOptions.initialRadius
.coerceIn(PlacePickerState.globalOptions.minRadius, PlacePickerState.globalOptions.maxRadius)
}
}

private fun gatherViews() {
Expand Down Expand Up @@ -104,6 +114,9 @@ class PlacePickerActivity : AppCompatActivity(), OnMapReadyCallback,
), 15F
)
)
if (PlacePickerState.globalOptions.enableRangeSelection) {
setupRadiusSelection()
}
}

override fun onCameraMoveStarted(reason: Int) {
Expand All @@ -120,6 +133,10 @@ class PlacePickerActivity : AppCompatActivity(), OnMapReadyCallback,
if (!PlacePickerState.globalOptions.enableGeocoding) {
pinViewAnimation.reverse()
animationIsUp = false
if (PlacePickerState.globalOptions.enableRangeSelection) {
updateRadiusHandlePosition()
updateCircle()
}
return
}
mapMoveTask?.cancel(true)
Expand All @@ -134,10 +151,18 @@ class PlacePickerActivity : AppCompatActivity(), OnMapReadyCallback,
supportActionBar?.subtitle = lastAddress?.featureName ?: "Unknown location"
pinViewAnimation.reverse()
animationIsUp = false
if (PlacePickerState.globalOptions.enableRangeSelection) {
updateRadiusHandlePosition()
updateCircle()
}
} catch (e: Exception) {
supportActionBar?.subtitle = ""
pinViewAnimation.reverse()
animationIsUp = false
if (PlacePickerState.globalOptions.enableRangeSelection) {
updateRadiusHandlePosition()
updateCircle()
}
}
}
}, 1, TimeUnit.SECONDS)
Expand Down Expand Up @@ -294,6 +319,9 @@ class PlacePickerActivity : AppCompatActivity(), OnMapReadyCallback,
country = add?.countryName ?: ""
}
}
if (PlacePickerState.globalOptions.enableRangeSelection) {
appendRadiusData()
}
if (item.itemId == R.id.action_done) {
PlacePickerState.globalResult.didCancel = false
PlacePickerState.globalPromise?.resolve(PlacePickerState.globalResult)
Expand All @@ -314,4 +342,121 @@ class PlacePickerActivity : AppCompatActivity(), OnMapReadyCallback,
return true
}

// Radius helpers
private fun setupRadiusSelection() {
setupRadiusHandle()
updateCircle()
updateRadiusHandlePosition()
}

private fun updateCircle() {
radiusCircle?.remove()
radiusCircle = mMap.addCircle(
CircleOptions()
.center(mMap.cameraPosition.target)
.radius(currentRadius)
.fillColor(parseFillColor())
.strokeColor(parseStrokeColor())
.strokeWidth(PlacePickerState.globalOptions.radiusStrokeWidth.toFloat())
)
}

private fun setupRadiusHandle() {
if (radiusHandle != null) return
val handle = View(this)
val size = 56
handle.layoutParams = android.view.ViewGroup.LayoutParams(size, size)
handle.background = pinView.background.mutate()
handle.background.setTint(Color.parseColor(PlacePickerState.globalOptions.color))
handle.setOnTouchListener { _, event ->
when (event.action) {
android.view.MotionEvent.ACTION_DOWN -> {
isDragging = true
true
}
android.view.MotionEvent.ACTION_MOVE -> {
if (!isDragging) return@setOnTouchListener true
val centerScreen = mMap.projection.toScreenLocation(mMap.cameraPosition.target)
val dx = event.rawX - centerScreen.x
val dy = event.rawY - centerScreen.y
val distancePx = kotlin.math.sqrt(dx*dx + dy*dy)
val metersPerPixel = metersPerPixelAtLatitude(mMap.cameraPosition.target.latitude)
val newRadius = (distancePx * metersPerPixel).toDouble()
.coerceIn(PlacePickerState.globalOptions.minRadius, PlacePickerState.globalOptions.maxRadius)
currentRadius = newRadius
updateRadiusHandlePosition()
true
}
android.view.MotionEvent.ACTION_UP, android.view.MotionEvent.ACTION_CANCEL -> {
isDragging = false
updateCircle()
true
}
else -> false
}
}
addContentView(handle, handle.layoutParams)
radiusHandle = handle
}

private fun updateRadiusHandlePosition() {
val handle = radiusHandle ?: return
val centerGeo = mMap.cameraPosition.target
val eastGeo = offsetCoordinate(centerGeo.latitude, centerGeo.longitude, currentRadius, 0.0)
val edgeScreen = mMap.projection.toScreenLocation(eastGeo)
handle.x = edgeScreen.x - handle.width / 2f
handle.y = edgeScreen.y - handle.height / 2f
}

private fun metersPerPixelAtLatitude(lat: Double): Float {
val earthCircumference = 40075016.686
val zoom = mMap.cameraPosition.zoom.toDouble()
val scale = Math.pow(2.0, zoom)
val metersPerPixel = (Math.cos(Math.toRadians(lat)) * earthCircumference) / (256.0 * scale)
return metersPerPixel.toFloat()
}

private fun parseFillColor(): Int {
val color = if (PlacePickerState.globalOptions.radiusColor.isNotEmpty()) PlacePickerState.globalOptions.radiusColor else PlacePickerState.globalOptions.color
val base = Color.parseColor(color)
return Color.argb(64, Color.red(base), Color.green(base), Color.blue(base))
}

private fun parseStrokeColor(): Int {
val color = if (PlacePickerState.globalOptions.radiusStrokeColor.isNotEmpty()) PlacePickerState.globalOptions.radiusStrokeColor else PlacePickerState.globalOptions.color
return Color.parseColor(color)
}

private fun appendRadiusData() {
PlacePickerState.globalResult.radius = currentRadius
val center = PlacePickerCoordinate().apply {
latitude = mMap.cameraPosition.target.latitude
longitude = mMap.cameraPosition.target.longitude
}
val ne = offsetCoordinate(center.latitude, center.longitude, currentRadius, currentRadius)
val sw = offsetCoordinate(center.latitude, center.longitude, -currentRadius, -currentRadius)
val bounds = BoundsCoordinates().apply {
northeast = PlacePickerCoordinate().apply {
latitude = ne.latitude
longitude = ne.longitude
}
southwest = PlacePickerCoordinate().apply {
latitude = sw.latitude
longitude = sw.longitude
}
}
PlacePickerState.globalResult.radiusCoordinates = RadiusCoordinates().apply {
this.center = center
this.bounds = bounds
}
}

private fun offsetCoordinate(lat: Double, lon: Double, metersEast: Double, metersNorth: Double): LatLng {
val earthRadius = 6378137.0
val dLat = metersNorth / earthRadius
val dLon = metersEast / (earthRadius * kotlin.math.cos(Math.toRadians(lat)))
val newLat = lat + Math.toDegrees(dLat)
val newLon = lon + Math.toDegrees(dLon)
return LatLng(newLat, newLon)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,25 @@ class PlacePickerOptions : Record {

@Field
val rejectOnCancel: Boolean = true

@Field
val enableRangeSelection: Boolean = false

@Field
val initialRadius: Double = 1000.0

@Field
val minRadius: Double = 100.0

@Field
val maxRadius: Double = 10000.0

@Field
val radiusColor: String = ""

@Field
val radiusStrokeColor: String = ""

@Field
val radiusStrokeWidth: Double = 2.0
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,24 @@ class PlacePickerResult : Record {

@Field
var didCancel: Boolean? = null

@Field
var radius: Double? = null

@Field
var radiusCoordinates: RadiusCoordinates? = null
}

class BoundsCoordinates : Record {
@Field
var northeast: PlacePickerCoordinate? = null
@Field
var southwest: PlacePickerCoordinate? = null
}

class RadiusCoordinates : Record {
@Field
var center: PlacePickerCoordinate? = null
@Field
var bounds: BoundsCoordinates? = null
}
Loading