diff --git a/.claude/implementations/infowindow-implementation.md b/.claude/implementations/infowindow-implementation.md new file mode 100644 index 00000000..0b203242 --- /dev/null +++ b/.claude/implementations/infowindow-implementation.md @@ -0,0 +1,218 @@ +# InfoWindow Implementation + +## 개요 + +네이버 지도 SDK의 InfoWindow 기능을 React Native Naver Map 라이브러리에 구현했습니다. +InfoWindow는 마커나 특정 좌표에 부가 정보를 표시하는 말풍선 형태의 오버레이입니다. + +## 구현 내용 + +### 1. TypeScript Spec 및 타입 정의 + +**파일**: `src/spec/RNCNaverMapInfoWindowNativeComponent.ts` + +- Fabric Component Spec 정의 +- BaseOverlay 속성 상속 +- InfoWindow 전용 속성: + - `coord`: 위치 좌표 + - `markerTag`: 연결할 마커 태그 (선택) + - `align`: 정렬 방향 + - `anchor`: 앵커 포인트 + - `offsetX`, `offsetY`: 오프셋 + - `alpha`: 불투명도 + - `text`: 표시 텍스트 + - `textSize`, `textColor`: 텍스트 스타일 + - `backgroundColor`: 배경색 + +### 2. Android 구현 + +#### RNCNaverMapInfoWindow.kt +**위치**: `android/src/main/java/com/mjstudio/reactnativenavermap/overlay/infowindow/` + +- `InfoWindow` 래핑 +- **커스텀 어댑터 패턴 구현** (`RNCNaverMapInfoWindowAdapter`) + - `InfoWindow.ViewAdapter()` 상속 + - `getView()` 메서드에서 커스텀 View 생성 + - TextView + FrameLayout으로 구성 + - GradientDrawable로 배경, 테두리, 라운드 코너 구현 +- 텍스트 및 스타일 동적 업데이트 +- 마커 연결 지원 (`identifier`를 통한 Marker Registry 조회) +- 열림/닫힘 상태 제어 + +#### RNCNaverMapInfoWindowManager.kt +**위치**: `android/src/main/java/com/mjstudio/reactnativenavermap/overlay/infowindow/` + +- ViewManager 구현 (Codegen Delegate 패턴) +- ReactProp을 통한 속성 처리 + +#### RNCNaverMapInfoWindowManagerSpec.kt +**위치**: `android/src/newarch/` + +- New Architecture Spec 클래스 +- ViewManagerDelegate 연결 + +### 3. iOS 구현 + +#### RNCNaverMapInfoWindow.h/mm +**위치**: `ios/Overlay/InfoWindow/` + +- `NMFInfoWindow` 래핑 +- Fabric Component View 구현 +- Props 업데이트 처리 +- **커스텀 데이터 소스 구현** (`RNCNaverMapInfoWindowDataSource`) + - `NMFOverlayImageDataSource` 프로토콜 채택 + - `viewWithOverlay:` 메서드로 UIView 직접 반환 + - UILabel 기반 텍스트 렌더링 + - CALayer를 통한 스타일링 (테두리, 라운드 코너, 배경색) + - 패딩 및 폰트 굵기 지원 +- 속성 변경 시 `invalidate()` 호출하여 자동 재렌더링 +- Retina 디스플레이 대응 +- `nmap::intToColor()` 함수로 색상 변환 + +### 4. React Component + +**파일**: `src/component/NaverMapInfoWindow.tsx` + +- Props interface 정의 (`NaverMapInfoWindowProps`) +- JSDoc 문서화 +- 사용 예제 포함 +- 기본값 설정 +- Color 처리 (`processColor`) +- Align 변환 + +### 5. 등록 및 Export + +- **Android**: `RNCNaverMapPackage.kt`에 ViewManager 등록 +- **iOS**: `RNCNaverMapViewImpl.h`에 import 추가, `insertReactSubview`에 처리 추가 +- **Export**: `src/index.tsx`에 컴포넌트 및 타입 export + +## 사용 예제 + +```tsx +import { NaverMapInfoWindow } from '@mj-studio/react-native-naver-map'; + +// 특정 좌표에 InfoWindow 표시 + + +// 마커에 연결된 InfoWindow + + +``` + +## 참고 자료 + +- [Android InfoWindow 공식 문서](https://navermaps.github.io/android-map-sdk/guide-ko/5-3.html) +- [iOS NMFInfoWindow API](https://navermaps.github.io/ios-map-sdk/reference/Classes/NMFInfoWindow.html) +- [iOS NMFOverlayImageDataSource 프로토콜](https://navermaps.github.io/ios-map-sdk/reference/Protocols/NMFOverlayImageDataSource.html) + +## 플랫폼별 스타일 지원 현황 + +### Android ✅ (완전 지원) +- ✅ `text`, `textSize`, `textColor` +- ✅ `fontWeight` - Bold/Regular/Medium/Semibold (100-900) +- ✅ `backgroundColor` +- ✅ `borderRadius` - 둥근 모서리 +- ✅ `borderWidth`, `borderColor` - 테두리 +- ✅ `padding` - 내부 여백 +- ✅ 마커 연결 (`identifier`) +- ✅ 열림/닫힘 제어 (`isOpen`) + +**구현 방식:** +```kotlin +// GradientDrawable로 커스텀 스타일 구현 +val drawable = GradientDrawable().apply { + setColor(backgroundColor) + cornerRadius = borderRadius + setStroke(borderWidth.toInt(), borderColor) +} +``` + +### iOS ✅ (완전 지원) +- ✅ `text`, `textSize`, `textColor` +- ✅ `fontWeight` - Regular/Medium/Semibold/Bold (100-900) +- ✅ `backgroundColor` +- ✅ `borderRadius` - 둥근 모서리 +- ✅ `borderWidth`, `borderColor` - 테두리 +- ✅ `padding` - 내부 여백 +- ✅ 마커 연결 (`identifier`) +- ✅ 열림/닫힘 제어 (`isOpen`) + +**구현 방식:** +```objective-c +// NMFOverlayImageDataSource 프로토콜의 viewWithOverlay: 메서드 구현 +- (UIView*)viewWithOverlay:(NMFOverlay*)overlay { + // UILabel과 UIView를 사용하여 커스텀 스타일 구현 + UIView* containerView = [[UIView alloc] init]; + containerView.backgroundColor = backgroundColor; + containerView.layer.cornerRadius = borderRadius; + containerView.layer.borderWidth = borderWidth; + containerView.layer.borderColor = borderColor.CGColor; + // ... 텍스트와 패딩 설정 + return containerView; +} +``` + +**주요 특징:** +- Retina 디스플레이 지원 +- 동적 스타일 업데이트 (`invalidate()` 호출) +- 빈 텍스트 처리 및 최소 크기 보장 +- Android와 동일한 모든 스타일 속성 지원 + +## 구현 패턴 + +이 구현은 다음 패턴을 따릅니다: + +- **Pattern #001**: Fabric Native Component Definition +- **Pattern #008**: Android ViewManager with Codegen +- **Pattern #004**: iOS Fabric Component Implementation + +## 테스트 + +구현 후 다음을 확인해야 합니다: + +1. Codegen 정상 실행 (`pnpm codegen`) +2. Lint 오류 없음 +3. 예제 앱에서 InfoWindow 표시 확인 +4. Android/iOS 모두에서 동작 확인 +5. 속성 변경 시 동적 업데이트 확인 + +## 완료 상태 + +- ✅ TypeScript Spec 및 타입 정의 +- ✅ Android 네이티브 구현 (모든 스타일 지원) +- ✅ iOS 네이티브 구현 (모든 스타일 지원) +- ✅ React Component 작성 +- ✅ Package 등록 +- ✅ Export 추가 +- ✅ 마커 연결 기능 (`identifier`) +- ✅ 열림/닫힘 제어 (`isOpen`) +- ✅ Marker Registry 구현 +- ✅ iOS 커스텀 스타일 (NMFOverlayImageDataSource 프로토콜 활용) + +## 사용 권장사항 + +- **텍스트 정보 표시**: InfoWindow 사용 (양쪽 플랫폼 모두 완전 지원) +- **커스텀 스타일**: InfoWindow 사용 (Android & iOS 모두 모든 스타일 속성 지원) +- **복잡한 인터랙션**: 필요한 경우 Marker의 Custom View 사용 고려 + diff --git a/android/src/main/java/com/mjstudio/reactnativenavermap/RNCNaverMapPackage.kt b/android/src/main/java/com/mjstudio/reactnativenavermap/RNCNaverMapPackage.kt index 871f0ab9..ce2df567 100644 --- a/android/src/main/java/com/mjstudio/reactnativenavermap/RNCNaverMapPackage.kt +++ b/android/src/main/java/com/mjstudio/reactnativenavermap/RNCNaverMapPackage.kt @@ -8,6 +8,7 @@ import com.mjstudio.reactnativenavermap.mapview.RNCNaverMapViewManager import com.mjstudio.reactnativenavermap.overlay.arrowheadpath.RNCNaverMapArrowheadPathManager import com.mjstudio.reactnativenavermap.overlay.circle.RNCNaverMapCircleManager import com.mjstudio.reactnativenavermap.overlay.ground.RNCNaverMapGroundManager +import com.mjstudio.reactnativenavermap.overlay.infowindow.RNCNaverMapInfoWindowManager import com.mjstudio.reactnativenavermap.overlay.marker.RNCNaverMapMarkerManager import com.mjstudio.reactnativenavermap.overlay.multipath.RNCNaverMapMultiPathManager import com.mjstudio.reactnativenavermap.overlay.path.RNCNaverMapPathManager @@ -26,6 +27,7 @@ class RNCNaverMapPackage : ReactPackage { add(RNCNaverMapMultiPathManager()) add(RNCNaverMapArrowheadPathManager()) add(RNCNaverMapGroundManager()) + add(RNCNaverMapInfoWindowManager()) } override fun createNativeModules(reactContext: ReactApplicationContext): List = emptyList() diff --git a/android/src/main/java/com/mjstudio/reactnativenavermap/mapview/RNCNaverMapView.kt b/android/src/main/java/com/mjstudio/reactnativenavermap/mapview/RNCNaverMapView.kt index 3d45df96..55926bf7 100644 --- a/android/src/main/java/com/mjstudio/reactnativenavermap/mapview/RNCNaverMapView.kt +++ b/android/src/main/java/com/mjstudio/reactnativenavermap/mapview/RNCNaverMapView.kt @@ -35,6 +35,9 @@ class RNCNaverMapView( private var map: NaverMap? = null val overlays = mutableListOf>() + // Marker registry for InfoWindow lookup + val markerRegistry = mutableMapOf() + private val locationOverlayImageRenderer by lazy { RNCNaverMapTaggedImageRenderer(context) } @@ -162,6 +165,13 @@ class RNCNaverMapView( is RNCNaverMapMarker -> { child.addToMap(map) overlays.add(index, child) + + // Register marker by identifier + val identifier = child.overlay.tag as? String + if (identifier != null) { + markerRegistry[identifier] = child + } + val visibility: Int = child.visibility child.visibility = INVISIBLE (child.parent as? ViewGroup)?.removeView(child) @@ -171,6 +181,10 @@ class RNCNaverMapView( } is RNCNaverMapOverlay<*> -> { + // If it's an InfoWindow, pass mapView reference + if (child is com.mjstudio.reactnativenavermap.overlay.infowindow.RNCNaverMapInfoWindow) { + child.setParentMapView(this) + } child.addToMap(map) overlays.add(index, child) } @@ -189,6 +203,7 @@ class RNCNaverMapView( } } overlays.clear() + markerRegistry.clear() map = null attacherGroup = null super.onDestroy() @@ -198,6 +213,12 @@ class RNCNaverMapView( withMap { map -> when (val child = overlays.removeAt(index)) { is RNCNaverMapMarker -> { + // Unregister marker + val identifier = child.overlay.tag as? String + if (identifier != null) { + markerRegistry.remove(identifier) + } + child.removeFromMap(map) attacherGroup?.removeView(child) } diff --git a/android/src/main/java/com/mjstudio/reactnativenavermap/mapview/RNCNaverMapViewWrapper.kt b/android/src/main/java/com/mjstudio/reactnativenavermap/mapview/RNCNaverMapViewWrapper.kt index 73bc32b4..71608826 100644 --- a/android/src/main/java/com/mjstudio/reactnativenavermap/mapview/RNCNaverMapViewWrapper.kt +++ b/android/src/main/java/com/mjstudio/reactnativenavermap/mapview/RNCNaverMapViewWrapper.kt @@ -6,6 +6,7 @@ import android.view.Choreographer import android.view.Choreographer.FrameCallback import android.view.MotionEvent import android.view.View +import android.view.View.MeasureSpec import android.widget.FrameLayout import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner @@ -178,14 +179,10 @@ class RNCNaverMapViewWrapper( when (ev?.action) { MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE, - -> { - parent?.requestDisallowInterceptTouchEvent(true) - } + -> parent?.requestDisallowInterceptTouchEvent(true) MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL, - -> { - parent?.requestDisallowInterceptTouchEvent(false) - } + -> parent?.requestDisallowInterceptTouchEvent(false) } } } diff --git a/android/src/main/java/com/mjstudio/reactnativenavermap/overlay/infowindow/RNCNaverMapInfoWindow.kt b/android/src/main/java/com/mjstudio/reactnativenavermap/overlay/infowindow/RNCNaverMapInfoWindow.kt new file mode 100644 index 00000000..dae0865a --- /dev/null +++ b/android/src/main/java/com/mjstudio/reactnativenavermap/overlay/infowindow/RNCNaverMapInfoWindow.kt @@ -0,0 +1,206 @@ +package com.mjstudio.reactnativenavermap.overlay.infowindow + +import android.annotation.SuppressLint +import android.view.View +import android.widget.TextView +import com.facebook.react.uimanager.ThemedReactContext +import com.mjstudio.reactnativenavermap.overlay.RNCNaverMapOverlay +import com.naver.maps.geometry.LatLng +import com.naver.maps.map.NaverMap +import com.naver.maps.map.overlay.InfoWindow +import com.naver.maps.map.overlay.Marker + +@SuppressLint("ViewConstructor") +class RNCNaverMapInfoWindow( + private val reactContext: ThemedReactContext, +) : RNCNaverMapOverlay(reactContext) { + private var targetMarker: Marker? = null + private var position: LatLng? = null + private var customAdapter: RNCNaverMapInfoWindowAdapter? = null + private var markerIdentifier: String? = null + private var shouldBeOpen: Boolean = true + private var currentMap: NaverMap? = null + private var parentMapView: com.mjstudio.reactnativenavermap.mapview.RNCNaverMapView? = null + + override val overlay: InfoWindow by lazy { + InfoWindow().apply { + customAdapter = RNCNaverMapInfoWindowAdapter(reactContext) + adapter = customAdapter as InfoWindow.Adapter + } + } + + override fun addToMap(map: NaverMap) { + currentMap = map + updateInfoWindowState() + } + + override fun removeFromMap(map: NaverMap) { + overlay.close() + currentMap = null + } + + fun setParentMapView(mapView: com.mjstudio.reactnativenavermap.mapview.RNCNaverMapView) { + parentMapView = mapView + } + + private fun updateInfoWindowState() { + if (!shouldBeOpen) { + overlay.close() + return + } + + val map = currentMap ?: return + + // Try to find marker by identifier first + val identifier = markerIdentifier + if (identifier != null) { + val markerView = parentMapView?.markerRegistry?.get(identifier) + if (markerView != null) { + // Open on marker (marker position is used automatically) + overlay.open(markerView.overlay) + return + } + } + + // Fall back to position + val pos = position + if (pos != null) { + overlay.position = pos + overlay.map = map + } + } + + override fun onDropViewInstance() { + overlay.close() + customAdapter = null + } + + fun setPosition(latLng: LatLng) { + position = latLng + updateInfoWindowState() + } + + fun setMarkerIdentifier(identifier: String?) { + markerIdentifier = identifier + updateInfoWindowState() + } + + fun setIsOpen(isOpen: Boolean) { + shouldBeOpen = isOpen + updateInfoWindowState() + } + + fun setText(text: String?) { + customAdapter?.text = text + customAdapter?.let { overlay.adapter = it as InfoWindow.Adapter } + } + + fun setTextSize(size: Float) { + customAdapter?.textSize = size + customAdapter?.let { overlay.adapter = it as InfoWindow.Adapter } + } + + fun setTextColor(color: Int) { + customAdapter?.textColor = color + customAdapter?.let { overlay.adapter = it as InfoWindow.Adapter } + } + + fun setFontWeight(weight: Int) { + customAdapter?.fontWeight = weight + customAdapter?.let { overlay.adapter = it as InfoWindow.Adapter } + } + + fun setInfoWindowBackgroundColor(color: Int) { + customAdapter?.backgroundColor = color + customAdapter?.let { overlay.adapter = it as InfoWindow.Adapter } + } + + fun setInfoWindowBorderRadius(radius: Float) { + customAdapter?.borderRadius = radius + customAdapter?.let { overlay.adapter = it as InfoWindow.Adapter } + } + + fun setInfoWindowBorderWidth(width: Float) { + customAdapter?.borderWidth = width + customAdapter?.let { overlay.adapter = it as InfoWindow.Adapter } + } + + fun setInfoWindowBorderColor(color: Int) { + customAdapter?.borderColor = color + customAdapter?.let { overlay.adapter = it as InfoWindow.Adapter } + } + + fun setInfoWindowPaddingHorizontal(padding: Float) { + customAdapter?.paddingHorizontal = padding + customAdapter?.let { overlay.adapter = it as InfoWindow.Adapter } + } + + fun setInfoWindowPaddingVertical(padding: Float) { + customAdapter?.paddingVertical = padding + customAdapter?.let { overlay.adapter = it as InfoWindow.Adapter } + } + + inner class RNCNaverMapInfoWindowAdapter( + private val context: ThemedReactContext, + ) : InfoWindow.ViewAdapter() { + var text: String? = null + var textSize: Float = 14f + var textColor: Int = android.graphics.Color.BLACK + var fontWeight: Int = 400 + var backgroundColor: Int = android.graphics.Color.WHITE + var borderRadius: Float = 5f + var borderWidth: Float = 1f + var borderColor: Int = android.graphics.Color.parseColor("#cccccc") + var paddingHorizontal: Float = 10f + var paddingVertical: Float = 10f + + override fun getView(infoWindow: InfoWindow): View { + val paddingHorizontalPx = this@RNCNaverMapInfoWindowAdapter.paddingHorizontal.toInt() + val paddingVerticalPx = this@RNCNaverMapInfoWindowAdapter.paddingVertical.toInt() + + val textView = TextView(context).apply { + this.text = this@RNCNaverMapInfoWindowAdapter.text ?: "" + this.textSize = this@RNCNaverMapInfoWindowAdapter.textSize + this.setTextColor(this@RNCNaverMapInfoWindowAdapter.textColor) + + // Font weight + val typeface = when { + this@RNCNaverMapInfoWindowAdapter.fontWeight >= 700 -> android.graphics.Typeface.BOLD + else -> android.graphics.Typeface.NORMAL + } + this.setTypeface(null, typeface) + + // Gravity + this.gravity = android.view.Gravity.CENTER + } + + // Container with border, background and padding + val container = android.widget.FrameLayout(context).apply { + // Add padding to container (horizontal and vertical separately) + this.setPadding(paddingHorizontalPx, paddingVerticalPx, paddingHorizontalPx, paddingVerticalPx) + + // Add text view with wrap content + addView( + textView, + android.widget.FrameLayout.LayoutParams( + android.widget.FrameLayout.LayoutParams.WRAP_CONTENT, + android.widget.FrameLayout.LayoutParams.WRAP_CONTENT, + ), + ) + + // Background and border using GradientDrawable + val drawable = android.graphics.drawable.GradientDrawable().apply { + setColor(this@RNCNaverMapInfoWindowAdapter.backgroundColor) + cornerRadius = this@RNCNaverMapInfoWindowAdapter.borderRadius + setStroke( + this@RNCNaverMapInfoWindowAdapter.borderWidth.toInt(), + this@RNCNaverMapInfoWindowAdapter.borderColor, + ) + } + background = drawable + } + + return container + } + } +} diff --git a/android/src/main/java/com/mjstudio/reactnativenavermap/overlay/infowindow/RNCNaverMapInfoWindowManager.kt b/android/src/main/java/com/mjstudio/reactnativenavermap/overlay/infowindow/RNCNaverMapInfoWindowManager.kt new file mode 100644 index 00000000..36c12947 --- /dev/null +++ b/android/src/main/java/com/mjstudio/reactnativenavermap/overlay/infowindow/RNCNaverMapInfoWindowManager.kt @@ -0,0 +1,238 @@ +package com.mjstudio.reactnativenavermap.overlay.infowindow + +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.annotations.ReactProp +import com.mjstudio.reactnativenavermap.RNCNaverMapInfoWindowManagerSpec +import com.mjstudio.reactnativenavermap.util.getLatLng +import com.mjstudio.reactnativenavermap.util.getPoint +import com.mjstudio.reactnativenavermap.util.isValidNumber +import com.mjstudio.reactnativenavermap.util.px +import com.naver.maps.map.overlay.InfoWindow + +class RNCNaverMapInfoWindowManager : RNCNaverMapInfoWindowManagerSpec() { + override fun getName(): String = NAME + + override fun createViewInstance(context: ThemedReactContext): RNCNaverMapInfoWindow = + RNCNaverMapInfoWindow(context) + + override fun onDropViewInstance(view: RNCNaverMapInfoWindow) { + super.onDropViewInstance(view) + view.onDropViewInstance() + } + + private fun RNCNaverMapInfoWindow?.withOverlay(fn: (InfoWindow) -> Unit) { + this?.overlay?.run(fn) + } + + @ReactProp(name = "coord") + override fun setCoord( + view: RNCNaverMapInfoWindow?, + value: ReadableMap?, + ) { + value.getLatLng()?.run { + view?.setPosition(this) + } + } + + @ReactProp(name = "zIndexValue") + override fun setZIndexValue( + view: RNCNaverMapInfoWindow?, + value: Int, + ) = view.withOverlay { + it.zIndex = value + } + + @ReactProp(name = "globalZIndexValue") + override fun setGlobalZIndexValue( + view: RNCNaverMapInfoWindow?, + value: Int, + ) = view.withOverlay { + if (isValidNumber(value)) { + it.globalZIndex = value + } + } + + @ReactProp(name = "isHidden") + override fun setIsHidden( + view: RNCNaverMapInfoWindow?, + value: Boolean, + ) = view.withOverlay { + it.isVisible = !value + } + + @ReactProp(name = "minZoom") + override fun setMinZoom( + view: RNCNaverMapInfoWindow?, + value: Double, + ) = view.withOverlay { + it.minZoom = value + } + + @ReactProp(name = "maxZoom") + override fun setMaxZoom( + view: RNCNaverMapInfoWindow?, + value: Double, + ) = view.withOverlay { + it.maxZoom = value + } + + @ReactProp(name = "isMinZoomInclusive") + override fun setIsMinZoomInclusive( + view: RNCNaverMapInfoWindow?, + value: Boolean, + ) = view.withOverlay { + it.isMinZoomInclusive = value + } + + @ReactProp(name = "isMaxZoomInclusive") + override fun setIsMaxZoomInclusive( + view: RNCNaverMapInfoWindow?, + value: Boolean, + ) = view.withOverlay { + it.isMaxZoomInclusive = value + } + + @ReactProp(name = "align") + override fun setAlign( + view: RNCNaverMapInfoWindow?, + value: Int, + ) { + // InfoWindow align will be handled when opening + // Stored for later use when marker is attached + } + + @ReactProp(name = "anchor") + override fun setAnchor( + view: RNCNaverMapInfoWindow?, + value: ReadableMap?, + ) = view.withOverlay { + value.getPoint()?.run { + it.anchor = this + } + } + + @ReactProp(name = "offsetX") + override fun setOffsetX( + view: RNCNaverMapInfoWindow?, + value: Int, + ) = view.withOverlay { + it.offsetX = value.px + } + + @ReactProp(name = "offsetY") + override fun setOffsetY( + view: RNCNaverMapInfoWindow?, + value: Int, + ) = view.withOverlay { + it.offsetY = value.px + } + + @ReactProp(name = "alpha") + override fun setAlpha( + view: RNCNaverMapInfoWindow?, + value: Double, + ) = view.withOverlay { + it.alpha = value.toFloat() + } + + @ReactProp(name = "text") + override fun setText( + view: RNCNaverMapInfoWindow?, + value: String?, + ) { + view?.setText(value) + } + + @ReactProp(name = "textSize") + override fun setTextSize( + view: RNCNaverMapInfoWindow?, + value: Double, + ) { + view?.setTextSize(value.toFloat()) + } + + @ReactProp(name = "textColor") + override fun setTextColor( + view: RNCNaverMapInfoWindow?, + value: Int, + ) { + view?.setTextColor(value) + } + + @ReactProp(name = "fontWeight", defaultInt = 400) + override fun setFontWeight( + view: RNCNaverMapInfoWindow?, + value: Int, + ) { + view?.setFontWeight(value) + } + + @ReactProp(name = "infoWindowBackgroundColor") + override fun setInfoWindowBackgroundColor( + view: RNCNaverMapInfoWindow?, + value: Int, + ) { + view?.setInfoWindowBackgroundColor(value) + } + + @ReactProp(name = "infoWindowBorderRadius", defaultFloat = 5f) + override fun setInfoWindowBorderRadius( + view: RNCNaverMapInfoWindow?, + value: Double, + ) { + view?.setInfoWindowBorderRadius(value.toFloat()) + } + + @ReactProp(name = "infoWindowBorderWidth", defaultFloat = 1f) + override fun setInfoWindowBorderWidth( + view: RNCNaverMapInfoWindow?, + value: Double, + ) { + view?.setInfoWindowBorderWidth(value.toFloat()) + } + + @ReactProp(name = "infoWindowBorderColor") + override fun setInfoWindowBorderColor( + view: RNCNaverMapInfoWindow?, + value: Int, + ) { + view?.setInfoWindowBorderColor(value) + } + + @ReactProp(name = "infoWindowPaddingHorizontal", defaultFloat = 10f) + override fun setInfoWindowPaddingHorizontal( + view: RNCNaverMapInfoWindow?, + value: Double, + ) { + view?.setInfoWindowPaddingHorizontal(value.toFloat()) + } + + @ReactProp(name = "infoWindowPaddingVertical", defaultFloat = 10f) + override fun setInfoWindowPaddingVertical( + view: RNCNaverMapInfoWindow?, + value: Double, + ) { + view?.setInfoWindowPaddingVertical(value.toFloat()) + } + + @ReactProp(name = "identifier") + override fun setIdentifier( + view: RNCNaverMapInfoWindow?, + value: String?, + ) { + view?.setMarkerIdentifier(value) + } + + @ReactProp(name = "isOpen", defaultBoolean = true) + override fun setIsOpen( + view: RNCNaverMapInfoWindow?, + value: Boolean, + ) { + view?.setIsOpen(value) + } + + companion object { + const val NAME = "RNCNaverMapInfoWindow" + } +} diff --git a/android/src/main/java/com/mjstudio/reactnativenavermap/overlay/marker/RNCNaverMapMarker.kt b/android/src/main/java/com/mjstudio/reactnativenavermap/overlay/marker/RNCNaverMapMarker.kt index 509e1ec7..6edcc402 100644 --- a/android/src/main/java/com/mjstudio/reactnativenavermap/overlay/marker/RNCNaverMapMarker.kt +++ b/android/src/main/java/com/mjstudio/reactnativenavermap/overlay/marker/RNCNaverMapMarker.kt @@ -29,150 +29,150 @@ class RNCNaverMapMarker( val reactContext: ThemedReactContext, ) : RNCNaverMapImageRenderableOverlay(reactContext), TrackableView { - private var customView: View? = null - private var customViewBitmap: Bitmap? = null - - private var isImageSetFromSubview = false - - private var lastCaptionKey = DEFAULT_CAPTION_KEY - private var lastSubCaptionKey = DEFAULT_CAPTION_KEY - - override val overlay: Marker by lazy { - Marker().apply { - setOnClickListener { - reactContext.emitEvent(id) { surfaceId, reactTag -> - NaverMapOverlayTapEvent( - surfaceId, - reactTag, - ) + private var customView: View? = null + private var customViewBitmap: Bitmap? = null + + private var isImageSetFromSubview = false + + private var lastCaptionKey = DEFAULT_CAPTION_KEY + private var lastSubCaptionKey = DEFAULT_CAPTION_KEY + + override val overlay: Marker by lazy { + Marker().apply { + setOnClickListener { + reactContext.emitEvent(id) { surfaceId, reactTag -> + NaverMapOverlayTapEvent( + surfaceId, + reactTag, + ) + } + true } - true } } - } - - override fun addToMap(map: NaverMap) { - overlay.map = map - } - override fun removeFromMap(map: NaverMap) { - overlay.map = null - } + override fun addToMap(map: NaverMap) { + overlay.map = map + } - override fun onDropViewInstance() { - overlay.map = null - overlay.onClickListener = null - super.onDropViewInstance() - } + override fun removeFromMap(map: NaverMap) { + overlay.map = null + } - fun setCustomView( - view: View, - index: Int, - ) { - super.addView(view, index) - isImageSetFromSubview = true - if (view.layoutParams == null) { - view.setLayoutParams( - LayoutParams( - LayoutParams.WRAP_CONTENT, - LayoutParams.WRAP_CONTENT, - ), - ) + override fun onDropViewInstance() { + overlay.map = null + overlay.onClickListener = null + super.onDropViewInstance() } - ViewChangesTracker.getInstance().addMarker(this) - customView = view - updateCustomView() - overlay.alpha = 1f - } - fun removeCustomView(index: Int) { - customView = null - ViewChangesTracker.getInstance().removeMarker(this) - if (customViewBitmap != null && !customViewBitmap!!.isRecycled) customViewBitmap!!.recycle() - isImageSetFromSubview = false - setImageWithLastImage() - super.removeView(children.elementAt(index)) - } + fun setCustomView( + view: View, + index: Int, + ) { + super.addView(view, index) + isImageSetFromSubview = true + if (view.layoutParams == null) { + view.setLayoutParams( + LayoutParams( + LayoutParams.WRAP_CONTENT, + LayoutParams.WRAP_CONTENT, + ), + ) + } + ViewChangesTracker.getInstance().addMarker(this) + customView = view + updateCustomView() + overlay.alpha = 1f + } - override fun requestLayout() { - super.requestLayout() - if (isEmpty() && customView != null) { + fun removeCustomView(index: Int) { customView = null - updateCustomView() + ViewChangesTracker.getInstance().removeMarker(this) + if (customViewBitmap != null && !customViewBitmap!!.isRecycled) customViewBitmap!!.recycle() + isImageSetFromSubview = false + setImageWithLastImage() + super.removeView(children.elementAt(index)) } - } - private fun updateCustomView() { - if (customViewBitmap == null || - customViewBitmap!!.isRecycled || - customViewBitmap?.width != overlay.width || - customViewBitmap?.height != overlay.height - ) { - customViewBitmap = - createBitmap(max(1, overlay.width), max(1, overlay.height), Bitmap.Config.ARGB_4444) + override fun requestLayout() { + super.requestLayout() + if (isEmpty() && customView != null) { + customView = null + updateCustomView() + } } - if (customView != null) { - customViewBitmap?.also { bitmap -> - bitmap.eraseColor(Color.TRANSPARENT) - val canvas = Canvas(bitmap) - draw(canvas) - setOverlayImage(OverlayImage.fromBitmap(bitmap)) + + private fun updateCustomView() { + if (customViewBitmap == null || + customViewBitmap!!.isRecycled || + customViewBitmap?.width != overlay.width || + customViewBitmap?.height != overlay.height + ) { + customViewBitmap = + createBitmap(max(1, overlay.width), max(1, overlay.height), Bitmap.Config.ARGB_4444) + } + if (customView != null) { + customViewBitmap?.also { bitmap -> + bitmap.eraseColor(Color.TRANSPARENT) + val canvas = Canvas(bitmap) + draw(canvas) + setOverlayImage(OverlayImage.fromBitmap(bitmap)) + } } } - } - override fun skipTryRender(): Boolean = isImageSetFromSubview + override fun skipTryRender(): Boolean = isImageSetFromSubview - override fun updateCustomForTracking(): Boolean = true + override fun updateCustomForTracking(): Boolean = true - override fun update() { - updateCustomView() - } + override fun update() { + updateCustomView() + } - override fun setOverlayAlpha(alpha: Float) { - overlay.alpha = alpha - } + override fun setOverlayAlpha(alpha: Float) { + overlay.alpha = alpha + } - override fun setOverlayImage(image: OverlayImage?) { - overlay.icon = - image ?: OverlayImage.fromBitmap(createBitmap(1, 1)) - } + override fun setOverlayImage(image: OverlayImage?) { + overlay.icon = + image ?: OverlayImage.fromBitmap(createBitmap(1, 1)) + } - fun updateCaption(value: ReadableMap?) { - value?.also { map -> - val key = map.getString("key") ?: DEFAULT_CAPTION_KEY - if (key == lastCaptionKey) return - lastCaptionKey = key - - overlay.captionText = map.getString("text") ?: "" - overlay.captionRequestedWidth = (map.getDoubleOrNull("requestedWidth") ?: 0.0).px - overlay.setCaptionAligns(map.getAlign("align")) - overlay.captionOffset = (map.getDoubleOrNull("offset") ?: 0.0).px - overlay.captionColor = map.getIntOrNull("color") ?: Color.BLACK - overlay.captionHaloColor = map.getIntOrNull("haloColor") ?: Color.TRANSPARENT - overlay.captionTextSize = map.getDouble("textSize").toFloat() - overlay.captionMinZoom = map.getDouble("minZoom") - overlay.captionMaxZoom = map.getDouble("maxZoom") + fun updateCaption(value: ReadableMap?) { + value?.also { map -> + val key = map.getString("key") ?: DEFAULT_CAPTION_KEY + if (key == lastCaptionKey) return + lastCaptionKey = key + + overlay.captionText = map.getString("text") ?: "" + overlay.captionRequestedWidth = (map.getDoubleOrNull("requestedWidth") ?: 0.0).px + overlay.setCaptionAligns(map.getAlign("align")) + overlay.captionOffset = (map.getDoubleOrNull("offset") ?: 0.0).px + overlay.captionColor = map.getIntOrNull("color") ?: Color.BLACK + overlay.captionHaloColor = map.getIntOrNull("haloColor") ?: Color.TRANSPARENT + overlay.captionTextSize = map.getDouble("textSize").toFloat() + overlay.captionMinZoom = map.getDouble("minZoom") + overlay.captionMaxZoom = map.getDouble("maxZoom") + } } - } - fun updateSubCaption(value: ReadableMap?) { - value?.also { map -> - val key = map.getString("key") ?: DEFAULT_CAPTION_KEY - if (key == lastSubCaptionKey) return - lastSubCaptionKey = key - - overlay.subCaptionText = map.getString("text") ?: "" - overlay.subCaptionColor = map.getIntOrNull("color") ?: Color.BLACK - overlay.subCaptionHaloColor = map.getIntOrNull("haloColor") ?: Color.TRANSPARENT - overlay.subCaptionTextSize = map.getDouble("textSize").toFloat() - overlay.subCaptionRequestedWidth = map.getDouble("requestedWidth").px - overlay.subCaptionMinZoom = map.getDouble("minZoom") - overlay.subCaptionMaxZoom = map.getDouble("maxZoom") + fun updateSubCaption(value: ReadableMap?) { + value?.also { map -> + val key = map.getString("key") ?: DEFAULT_CAPTION_KEY + if (key == lastSubCaptionKey) return + lastSubCaptionKey = key + + overlay.subCaptionText = map.getString("text") ?: "" + overlay.subCaptionColor = map.getIntOrNull("color") ?: Color.BLACK + overlay.subCaptionHaloColor = map.getIntOrNull("haloColor") ?: Color.TRANSPARENT + overlay.subCaptionTextSize = map.getDouble("textSize").toFloat() + overlay.subCaptionRequestedWidth = map.getDouble("requestedWidth").px + overlay.subCaptionMinZoom = map.getDouble("minZoom") + overlay.subCaptionMaxZoom = map.getDouble("maxZoom") + } } - } - companion object { - const val DEFAULT_CAPTION_KEY = "DEFAULT" + companion object { + const val DEFAULT_CAPTION_KEY = "DEFAULT" + } } -} diff --git a/android/src/main/java/com/mjstudio/reactnativenavermap/overlay/marker/RNCNaverMapMarkerManager.kt b/android/src/main/java/com/mjstudio/reactnativenavermap/overlay/marker/RNCNaverMapMarkerManager.kt index 3d2f71c9..9589a5e3 100644 --- a/android/src/main/java/com/mjstudio/reactnativenavermap/overlay/marker/RNCNaverMapMarkerManager.kt +++ b/android/src/main/java/com/mjstudio/reactnativenavermap/overlay/marker/RNCNaverMapMarkerManager.kt @@ -238,6 +238,14 @@ class RNCNaverMapMarkerManager : RNCNaverMapMarkerManagerSpec view?.updateSubCaption(value) } + @ReactProp(name = "identifier") + override fun setIdentifier( + view: RNCNaverMapMarker?, + value: String?, + ) = view.withOverlay { + // Set marker tag for InfoWindow to find this marker + it.tag = value + } companion object { const val NAME = "RNCNaverMapMarker" } diff --git a/android/src/newarch/RNCNaverMapInfoWindowManagerSpec.kt b/android/src/newarch/RNCNaverMapInfoWindowManagerSpec.kt new file mode 100644 index 00000000..2370045e --- /dev/null +++ b/android/src/newarch/RNCNaverMapInfoWindowManagerSpec.kt @@ -0,0 +1,19 @@ +package com.mjstudio.reactnativenavermap + +import android.view.View +import com.facebook.react.uimanager.SimpleViewManager +import com.facebook.react.uimanager.ViewManagerDelegate +import com.facebook.react.viewmanagers.RNCNaverMapInfoWindowManagerDelegate +import com.facebook.react.viewmanagers.RNCNaverMapInfoWindowManagerInterface + +abstract class RNCNaverMapInfoWindowManagerSpec : + SimpleViewManager(), + RNCNaverMapInfoWindowManagerInterface { + private val mDelegate: ViewManagerDelegate + + init { + mDelegate = RNCNaverMapInfoWindowManagerDelegate(this) + } + + override fun getDelegate(): ViewManagerDelegate = mDelegate +} diff --git a/docs/content/docs/components/overlays/meta.json b/docs/content/docs/components/overlays/meta.json index 3f7ff198..6828a5cf 100644 --- a/docs/content/docs/components/overlays/meta.json +++ b/docs/content/docs/components/overlays/meta.json @@ -4,6 +4,7 @@ "pages": [ "common-overlay-usage", "naver-map-marker-overlay", + "naver-map-info-window", "naver-map-circle-overlay", "naver-map-polygon-overlay", "naver-map-polyline-overlay", diff --git a/docs/content/docs/components/overlays/meta.ko.json b/docs/content/docs/components/overlays/meta.ko.json index ba2482b5..b511f43b 100644 --- a/docs/content/docs/components/overlays/meta.ko.json +++ b/docs/content/docs/components/overlays/meta.ko.json @@ -4,6 +4,7 @@ "pages": [ "common-overlay-usage", "naver-map-marker-overlay", + "naver-map-info-window", "naver-map-circle-overlay", "naver-map-polygon-overlay", "naver-map-polyline-overlay", diff --git a/docs/content/docs/components/overlays/naver-map-info-window.ko.mdx b/docs/content/docs/components/overlays/naver-map-info-window.ko.mdx new file mode 100644 index 00000000..1d620564 --- /dev/null +++ b/docs/content/docs/components/overlays/naver-map-info-window.ko.mdx @@ -0,0 +1,61 @@ +--- +title: "NaverMapInfoWindow" +description: "마커 또는 좌표 위에 말풍선(정보창)을 표시하는 컴포넌트" +--- + +## Overview + +NaverMapInfoWindow는 지도 위에 정보 말풍선을 표시합니다. +`identifier`로 마커에 연결할 수 있고, `latitude`/`longitude`로 임의의 좌표에 직접 표시할 수도 있습니다. + +플랫폼 참고: +- Android: 폰트, 배경/테두리/패딩 등 전체 스타일 지원 +- iOS: 모든 스타일 속성 지원 (NMFOverlayImageDataSource 프로토콜 활용) + +## Basic Usage + +```tsx +import { NaverMapView, NaverMapMarkerOverlay, NaverMapInfoWindow } from '@mj-studio/react-native-naver-map'; + +function MapWithInfoWindow() { + return ( + + {/* 1) identifier로 마커에 연결 */} + + + + {/* 2) 좌표에 직접 표시 */} + + + ); +} +``` + +## Props + +`NaverMapInfoWindowProps` + + + +## 비고 + +- `identifier`가 설정되면 동일한 `identifier`를 가진 마커 위에 정보창이 열립니다. +- `identifier`가 없으면 `latitude`와 `longitude` 좌표에 표시됩니다. +- `align`, `anchor`, `offsetX/offsetY`로 마커 주변 위치를 세밀하게 조정할 수 있습니다. +- Android와 iOS 모두 모든 스타일 속성을 완전히 지원합니다 (폰트, 배경색, 테두리, 패딩 등). + + diff --git a/docs/content/docs/components/overlays/naver-map-info-window.mdx b/docs/content/docs/components/overlays/naver-map-info-window.mdx new file mode 100644 index 00000000..9ed54bc5 --- /dev/null +++ b/docs/content/docs/components/overlays/naver-map-info-window.mdx @@ -0,0 +1,61 @@ +--- +title: "NaverMapInfoWindow" +description: "Component for displaying info windows above markers or coordinates" +--- + +## Overview + +NaverMapInfoWindow displays an information balloon on the map. +It can be attached to a marker via `identifier` or rendered at an arbitrary coordinate using `latitude` and `longitude`. + +Platform notes: +- Android: supports full styling +- iOS: supports full styling (using NMFOverlayImageDataSource protocol) + +## Basic Usage + +```tsx +import { NaverMapView, NaverMapMarkerOverlay, NaverMapInfoWindow } from '@mj-studio/react-native-naver-map'; + +function MapWithInfoWindow() { + return ( + + {/* 1) Attach to a marker via identifier */} + + + + {/* 2) Render at a coordinate directly */} + + + ); +} +``` + +## Props + +`NaverMapInfoWindowProps` + + + +## Notes + +- When `identifier` is provided, the info window opens above the marker that has the same `identifier`. +- If `identifier` is omitted, `latitude` and `longitude` must be provided and the window is shown at the coordinate. +- `align`, `anchor`, and `offsetX/offsetY` help fine-tune placement around the marker. +- Both Android and iOS support full styling customization (font, radius, border, background, padding). + + diff --git a/example/src/App.tsx b/example/src/App.tsx index f4702f59..d021d1e8 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -20,6 +20,7 @@ import { CitiesScreen } from './screens/CitiesScreen'; import { ClusteringScreen } from './screens/ClusteringScreen'; import { CommonScreen } from './screens/CommonScreen'; import { GroundScreen } from './screens/GroundScreen'; +import { InfoWindowScreen } from './screens/InfoWindowScreen'; import { LocationOverlayScreen } from './screens/LocationOverlayScreen'; import { MarkerScreen } from './screens/MarkerScreen'; import { MultiPathScreen } from './screens/MultiPathScreen'; @@ -41,6 +42,7 @@ const SCREENS = [ { id: 'clustering', title: 'Clustering' }, { id: 'locationOverlay', title: 'Location Overlay' }, { id: 'cities', title: 'Cities (Performance Test)' }, + { id: 'infowindow', title: 'InfoWindow' }, ]; export default function App() { diff --git a/example/src/screens/InfoWindowScreen.tsx b/example/src/screens/InfoWindowScreen.tsx new file mode 100644 index 00000000..a53a6bcd --- /dev/null +++ b/example/src/screens/InfoWindowScreen.tsx @@ -0,0 +1,128 @@ +import { + NaverMapInfoWindow, + NaverMapMarkerOverlay, +} from '@mj-studio/react-native-naver-map'; +import React from 'react'; +import { Platform } from 'react-native'; +import { Header } from '../components/Header'; +import { ScreenLayout } from '../components/ScreenLayout'; + +const Cameras = { + Jeju: { + latitude: 33.39530773, + longitude: 126.54656715029, + zoom: 8, + }, +}; + +export const InfoWindowScreen = ({ onBack }: { onBack: () => void }) => { + return ( + <> +
+ + {/* infoWindow 와 연결된 마커들 */} + + + + + {/* infoWindow 1: 마커와 연결된 infoWindow (font bold 사용) */} + + + {/* infoWindow 2: 마커와 연결된 infoWindow (borderRadius, backgroundColor 사용) */} + + + {/* infoWindow 3: 마커와 연결된 infoWindow (all custom styles) */} + + + {/* infoWindow 4: 좌표에 독립적으로 표시된 infoWindow */} + + + {/* infoWindow 5: 처음부터 닫힌 infoWindow */} + + + + ); +}; diff --git a/ios/Overlay/InfoWindow/RNCNaverMapInfoWindow.h b/ios/Overlay/InfoWindow/RNCNaverMapInfoWindow.h new file mode 100644 index 00000000..f214d3f3 --- /dev/null +++ b/ios/Overlay/InfoWindow/RNCNaverMapInfoWindow.h @@ -0,0 +1,34 @@ +// +// RNCNaverMapInfoWindow.h +// mj-studio-react-native-naver-map +// +// Created by AI Assistant +// + +#import "ColorUtil.h" +#import "FnUtil.h" +#import "MacroUtil.h" +#import +#import +#import +#import +#import + +#import "RCTFabricComponentsPlugins.h" +#import +#import +#import +#import +#import + +@class RNCNaverMapViewImpl; +@class RNCNaverMapMarker; + +@interface RNCNaverMapInfoWindow : RCTViewComponentView +@property(nonatomic, strong) NMFInfoWindow* inner; + +- (void)setCurrentMapView:(NMFMapView*)mapView; +- (void)setParentMapViewImpl:(RNCNaverMapViewImpl*)mapViewImpl; +- (void)updateInfoWindowState; + +@end diff --git a/ios/Overlay/InfoWindow/RNCNaverMapInfoWindow.mm b/ios/Overlay/InfoWindow/RNCNaverMapInfoWindow.mm new file mode 100644 index 00000000..7a839c2b --- /dev/null +++ b/ios/Overlay/InfoWindow/RNCNaverMapInfoWindow.mm @@ -0,0 +1,307 @@ +// +// RNCNaverMapInfoWindow.mm +// mj-studio-react-native-naver-map +// +// Created by AI Assistant +// + +#import "RNCNaverMapInfoWindow.h" +#import "RNCNaverMapMarker.h" +#import "RNCNaverMapViewImpl.h" +#import + +using namespace facebook::react; + +// Custom data source for InfoWindow with styling support +@interface RNCNaverMapInfoWindowDataSource : NSObject +@property(nonatomic, strong) NSString* text; +@property(nonatomic, assign) CGFloat textSize; +@property(nonatomic, strong) UIColor* textColor; +@property(nonatomic, assign) NSInteger fontWeight; +@property(nonatomic, strong) UIColor* backgroundColor; +@property(nonatomic, assign) CGFloat borderRadius; +@property(nonatomic, assign) CGFloat borderWidth; +@property(nonatomic, strong) UIColor* borderColor; +@property(nonatomic, assign) CGFloat paddingHorizontal; +@property(nonatomic, assign) CGFloat paddingVertical; +@end + +@implementation RNCNaverMapInfoWindowDataSource + +- (instancetype)init { + if (self = [super init]) { + _text = @""; + _textSize = 14.0; + _textColor = [UIColor blackColor]; + _fontWeight = 400; + _backgroundColor = [UIColor whiteColor]; + _borderRadius = 5.0; + _borderWidth = 1.0; + _borderColor = [UIColor colorWithRed:0.8 green:0.8 blue:0.8 alpha:1.0]; + _paddingHorizontal = 10.0; + _paddingVertical = 10.0; + } + return self; +} + +- (UIView*)viewWithOverlay:(NMFOverlay*)overlay { + // Return the styled view directly + return [self createStyledView]; +} + +- (UIView*)createStyledView { + // Create label with text + UILabel* label = [[UILabel alloc] init]; + NSString* displayText = (_text && _text.length > 0) ? _text : @" "; + label.text = displayText; + label.font = [self createFont]; + label.textColor = _textColor; + label.textAlignment = NSTextAlignmentCenter; + label.numberOfLines = 0; + + // Calculate label size + CGSize maxSize = CGSizeMake(300, CGFLOAT_MAX); + CGSize labelSize = [displayText boundingRectWithSize:maxSize + options:NSStringDrawingUsesLineFragmentOrigin + attributes:@{NSFontAttributeName : label.font} + context:nil] + .size; + + // Ensure minimum size for empty or very small text + CGFloat minWidth = 20.0; + CGFloat minHeight = _textSize; + labelSize.width = MAX(ceil(labelSize.width), minWidth); + labelSize.height = MAX(ceil(labelSize.height), minHeight); + + // Add padding (horizontal and vertical separately) + CGFloat totalPaddingHorizontal = _paddingHorizontal * 2; + CGFloat totalPaddingVertical = _paddingVertical * 2; + CGSize containerSize = + CGSizeMake(labelSize.width + totalPaddingHorizontal, labelSize.height + totalPaddingVertical); + + // Create container view + UIView* containerView = + [[UIView alloc] initWithFrame:CGRectMake(0, 0, containerSize.width, containerSize.height)]; + containerView.backgroundColor = _backgroundColor; + containerView.layer.cornerRadius = _borderRadius; + containerView.layer.borderWidth = _borderWidth; + containerView.layer.borderColor = _borderColor.CGColor; + containerView.clipsToBounds = YES; + + // Add label to container with padding + label.frame = CGRectMake(_paddingHorizontal, _paddingVertical, labelSize.width, labelSize.height); + [containerView addSubview:label]; + + return containerView; +} + +- (UIFont*)createFont { + CGFloat fontSize = _textSize; + + if (_fontWeight >= 700) { + return [UIFont boldSystemFontOfSize:fontSize]; + } else if (_fontWeight >= 600) { + return [UIFont systemFontOfSize:fontSize weight:UIFontWeightSemibold]; + } else if (_fontWeight >= 500) { + return [UIFont systemFontOfSize:fontSize weight:UIFontWeightMedium]; + } else { + return [UIFont systemFontOfSize:fontSize weight:UIFontWeightRegular]; + } +} + +@end + +@interface RNCNaverMapInfoWindow () + +@end + +@implementation RNCNaverMapInfoWindow { + NSString* _markerIdentifier; + BOOL _shouldBeOpen; + NMFMapView* _currentMapView; + RNCNaverMapViewImpl* _parentMapViewImpl; + RNCNaverMapInfoWindowDataSource* _customDataSource; +} + +- (RCTBridge*)bridge { + return [RCTBridge currentBridge]; +} + +- (std::shared_ptr)emitter { + if (!_eventEmitter) + return nullptr; + return std::static_pointer_cast(_eventEmitter); +} + +- (instancetype)init { + if ((self = [super init])) { + _inner = [NMFInfoWindow new]; + _shouldBeOpen = YES; // Default isOpen = true + + // Create custom data source with styling support + _customDataSource = [[RNCNaverMapInfoWindowDataSource alloc] init]; + _inner.dataSource = _customDataSource; + } + + return self; +} + +- (instancetype)initWithFrame:(CGRect)frame { + if (self = [super initWithFrame:frame]) { + static const auto defaultProps = std::make_shared(); + _props = defaultProps; + } + + return self; +} + +- (void)setCurrentMapView:(NMFMapView*)mapView { + _currentMapView = mapView; + [self updateInfoWindowState]; +} + +- (void)setParentMapViewImpl:(RNCNaverMapViewImpl*)mapViewImpl { + _parentMapViewImpl = mapViewImpl; + [self updateInfoWindowState]; +} + +- (void)updateInfoWindowState { + if (!_shouldBeOpen) { + [_inner close]; + return; + } + + if (!_currentMapView) + return; + + // Try to find marker by identifier first + if (_markerIdentifier && _markerIdentifier.length > 0 && _parentMapViewImpl) { + RNCNaverMapMarker* markerView = _parentMapViewImpl.markerRegistry[_markerIdentifier]; + if (markerView) { + // Open on marker (marker position is used automatically) + [_inner openWithMarker:markerView.inner]; + return; + } + } + + // Fall back to position + _inner.mapView = _currentMapView; +} + +- (void)updateProps:(Props::Shared const&)props oldProps:(Props::Shared const&)oldProps { + const auto& prev = *std::static_pointer_cast(_props); + const auto& next = *std::static_pointer_cast(props); + + if (!nmap::isCoordEqual(prev.coord, next.coord)) { + _inner.position = nmap::createLatLng(next.coord); + [self updateInfoWindowState]; + } + + if (prev.zIndexValue != next.zIndexValue) + _inner.zIndex = next.zIndexValue; + if (prev.globalZIndexValue != next.globalZIndexValue && isValidNumber(next.globalZIndexValue)) + _inner.globalZIndex = next.globalZIndexValue; + if (prev.isHidden != next.isHidden) + _inner.hidden = next.isHidden; + if (prev.minZoom != next.minZoom) + _inner.minZoom = next.minZoom; + if (prev.maxZoom != next.maxZoom) + _inner.maxZoom = next.maxZoom; + if (prev.isMinZoomInclusive != next.isMinZoomInclusive) + _inner.isMinZoomInclusive = next.isMinZoomInclusive; + if (prev.isMaxZoomInclusive != next.isMaxZoomInclusive) + _inner.isMaxZoomInclusive = next.isMaxZoomInclusive; + + if (!nmap::isAnchorEqual(prev.anchor, next.anchor)) + _inner.anchor = nmap::createAnchorCGPoint(next.anchor); + + if (prev.offsetX != next.offsetX) + _inner.offsetX = next.offsetX; + if (prev.offsetY != next.offsetY) + _inner.offsetY = next.offsetY; + + if (prev.alpha != next.alpha) + _inner.alpha = next.alpha; + + // Identifier handling + if (prev.identifier != next.identifier) { + _markerIdentifier = getNsStr(next.identifier); + [self updateInfoWindowState]; + } + + // IsOpen handling + if (prev.isOpen != next.isOpen) { + _shouldBeOpen = next.isOpen; + [self updateInfoWindowState]; + } + + // Text content and styling (now supported via custom data source!) + BOOL needsRedraw = NO; + + if (prev.text != next.text) { + _customDataSource.text = getNsStr(next.text); + needsRedraw = YES; + } + + if (prev.textSize != next.textSize) { + _customDataSource.textSize = next.textSize; + needsRedraw = YES; + } + + if (prev.textColor != next.textColor) { + _customDataSource.textColor = nmap::intToColor(next.textColor); + needsRedraw = YES; + } + + if (prev.fontWeight != next.fontWeight) { + _customDataSource.fontWeight = next.fontWeight; + needsRedraw = YES; + } + + if (prev.infoWindowBackgroundColor != next.infoWindowBackgroundColor) { + _customDataSource.backgroundColor = nmap::intToColor(next.infoWindowBackgroundColor); + needsRedraw = YES; + } + + if (prev.infoWindowBorderRadius != next.infoWindowBorderRadius) { + _customDataSource.borderRadius = next.infoWindowBorderRadius; + needsRedraw = YES; + } + + if (prev.infoWindowBorderWidth != next.infoWindowBorderWidth) { + _customDataSource.borderWidth = next.infoWindowBorderWidth; + needsRedraw = YES; + } + + if (prev.infoWindowBorderColor != next.infoWindowBorderColor) { + _customDataSource.borderColor = nmap::intToColor(next.infoWindowBorderColor); + needsRedraw = YES; + } + + if (prev.infoWindowPaddingHorizontal != next.infoWindowPaddingHorizontal) { + _customDataSource.paddingHorizontal = next.infoWindowPaddingHorizontal; + needsRedraw = YES; + } + + if (prev.infoWindowPaddingVertical != next.infoWindowPaddingVertical) { + _customDataSource.paddingVertical = next.infoWindowPaddingVertical; + needsRedraw = YES; + } + + // Redraw the info window if any styling property changed + if (needsRedraw) { + [_inner invalidate]; + } + + [super updateProps:props oldProps:oldProps]; +} + +Class RNCNaverMapInfoWindowCls(void) { + return RNCNaverMapInfoWindow.class; +} + ++ (ComponentDescriptorProvider)componentDescriptorProvider { + return concreteComponentDescriptorProvider(); +} + +@end diff --git a/ios/Overlay/Marker/RNCNaverMapMarker.mm b/ios/Overlay/Marker/RNCNaverMapMarker.mm index fa122094..71ccb1ba 100644 --- a/ios/Overlay/Marker/RNCNaverMapMarker.mm +++ b/ios/Overlay/Marker/RNCNaverMapMarker.mm @@ -228,6 +228,11 @@ - (void)updateProps:(Props::Shared const&)props oldProps:(Props::Shared const&)o _inner.subCaptionMaxZoom = caption.maxZoom; } + // Set identifier as tag for InfoWindow lookup + if (prev.identifier != next.identifier) { + _inner.userInfo = @{@"identifier" : getNsStr(next.identifier)}; + } + [super updateProps:props oldProps:oldProps]; // Ensure touch handler is properly set after marker properties are updated diff --git a/ios/RNCNaverMapViewImpl.h b/ios/RNCNaverMapViewImpl.h index 551ba899..739263fb 100644 --- a/ios/RNCNaverMapViewImpl.h +++ b/ios/RNCNaverMapViewImpl.h @@ -15,6 +15,7 @@ #import "RNCNaverMapClusterKey.h" #import "RNCNaverMapClusterMarkerUpdater.h" #import "RNCNaverMapGround.h" +#import "RNCNaverMapInfoWindow.h" #import "RNCNaverMapLeafMarkerUpdater.h" #import "RNCNaverMapMarker.h" #import "RNCNaverMapMultiPath.h" @@ -47,6 +48,9 @@ using namespace facebook::react; @property(nonatomic, assign) NSInteger animationDuration; @property(nonatomic, assign) NSInteger animationEasing; +// Marker registry for InfoWindow lookup +@property(nonatomic, strong) NSMutableDictionary* markerRegistry; + - (void)setLocationOverlay:(const RNCNaverMapViewLocationOverlayStruct&)locationOverlay; @end diff --git a/ios/RNCNaverMapViewImpl.mm b/ios/RNCNaverMapViewImpl.mm index 76d8f28b..2fafa5d2 100644 --- a/ios/RNCNaverMapViewImpl.mm +++ b/ios/RNCNaverMapViewImpl.mm @@ -38,6 +38,7 @@ - (RCTBridge*)bridge { - (instancetype)initWithFrame:(CGRect)frame { if (self = [super initWithFrame:frame]) { _reactSubviews = [NSMutableArray new]; + _markerRegistry = [NSMutableDictionary new]; [self.mapView addCameraDelegate:self]; [self.mapView setTouchDelegate:self]; @@ -70,6 +71,7 @@ - (void)callImageCancllers { - (void)dealloc { [_reactSubviews removeAllObjects]; + [_markerRegistry removeAllObjects]; [self callImageCancllers]; } @@ -87,8 +89,15 @@ - (void)insertReactSubview:(id)subview atIndex:(NSInteger)atIndex // Our desired API is to pass up markers/overlays as children to the mapview component. // This is where we intercept them and do the appropriate underlying mapview action. if ([subview isKindOfClass:[RNCNaverMapMarker class]]) { - auto marker = static_cast(subview).inner; + auto markerView = static_cast(subview); + auto marker = markerView.inner; marker.mapView = self.mapView; + + // Register marker by identifier + NSString* identifier = marker.userInfo[@"identifier"]; + if (identifier && identifier.length > 0) { + _markerRegistry[identifier] = markerView; + } } else if ([subview isKindOfClass:[RNCNaverMapCircle class]]) { auto marker = static_cast(subview).inner; marker.mapView = self.mapView; @@ -113,6 +122,10 @@ - (void)insertReactSubview:(id)subview atIndex:(NSInteger)atIndex marker.overlayImage = NMF_MARKER_IMAGE_GREEN; } marker.mapView = self.mapView; + } else if ([subview isKindOfClass:[RNCNaverMapInfoWindow class]]) { + auto infoWindowView = static_cast(subview); + [infoWindowView setCurrentMapView:self.mapView]; + [infoWindowView setParentMapViewImpl:self]; } else { NSArray>* childSubviews = [subview reactSubviews]; for (int i = 0; i < childSubviews.count; i++) { @@ -126,7 +139,15 @@ - (void)removeReactSubview:(UIView*)subview { // similarly, when the children are being removed we have to do the appropriate // underlying mapview action here. if ([subview isKindOfClass:[RNCNaverMapMarker class]]) { - auto marker = static_cast(subview).inner; + auto markerView = static_cast(subview); + auto marker = markerView.inner; + + // Unregister marker + NSString* identifier = marker.userInfo[@"identifier"]; + if (identifier && identifier.length > 0) { + [_markerRegistry removeObjectForKey:identifier]; + } + marker.mapView = nil; marker.touchHandler = nil; } else if ([subview isKindOfClass:[RNCNaverMapCircle class]]) { diff --git a/package.json b/package.json index 6f6eb35e..58c96092 100644 --- a/package.json +++ b/package.json @@ -166,6 +166,7 @@ "RNCNaverMapArrowheadPath": "RNCNaverMapArrowheadPath", "RNCNaverMapCircle": "RNCNaverMapCircle", "RNCNaverMapGround": "RNCNaverMapGround", + "RNCNaverMapInfoWindow": "RNCNaverMapInfoWindow", "RNCNaverMapMarker": "RNCNaverMapMarker", "RNCNaverMapMultiPath": "RNCNaverMapMultiPath", "RNCNaverMapPath": "RNCNaverMapPath", diff --git a/src/component/NaverMapInfoWindow.tsx b/src/component/NaverMapInfoWindow.tsx new file mode 100644 index 00000000..7d7b5c05 --- /dev/null +++ b/src/component/NaverMapInfoWindow.tsx @@ -0,0 +1,244 @@ +import React, { type PropsWithChildren } from 'react'; +import { type ColorValue, processColor } from 'react-native'; +import type { Double } from 'react-native/Libraries/Types/CodegenTypes'; +import { getAlignIntValue } from '../internal/Util'; +import { Const } from '../internal/util/Const'; +import NativeNaverMapInfoWindow from '../spec/RNCNaverMapInfoWindowNativeComponent'; +import type { Align } from '../types/Align'; +import type { BaseOverlayProps } from '../types/BaseOverlayProps'; +import type { Coord } from '../types/Coord'; + +export interface NaverMapInfoWindowProps + extends BaseOverlayProps, + Coord, + PropsWithChildren<{}> { + /** + * InfoWindow가 열릴 마커의 identifier + * 지정하면 해당 identifier를 가진 마커 위에 InfoWindow가 열립니다. + * identifier가 없으면 latitude, longitude 좌표에 직접 표시됩니다. + */ + identifier?: string; + + /** + * InfoWindow를 처음부터 열린 상태로 표시할지 여부 + * @default true + */ + isOpen?: boolean; + + /** + * InfoWindow가 마커에 대해 열릴 때의 정렬 방향 + * @default 'Top' + */ + align?: Align; + + /** + * 앵커 포인트를 지정합니다. + * 왼쪽 위가 (0, 0), 오른쪽 아래가 (1, 1)인 비율로 표현합니다. + * @default {x: 0.5, y: 1} + */ + anchor?: { x: Double; y: Double }; + + /** + * X축 오프셋 (픽셀) + * @default 0 + */ + offsetX?: number; + + /** + * Y축 오프셋 (픽셀) + * @default 0 + */ + offsetY?: number; + + /** + * InfoWindow의 불투명도 (0~1) + * @default 1 + */ + alpha?: Double; + + /** + * InfoWindow에 표시할 텍스트 + */ + text?: string; + + /** + * 텍스트 크기 + * @default 14 + */ + textSize?: Double; + + /** + * 텍스트 색상 + * @default 'black' + */ + textColor?: ColorValue; + + /** + * 폰트 굵기 + * 'normal' | 'bold' | '100' | '200' | ... | '900' + * @default 'normal' + */ + fontWeight?: + | 'normal' + | 'bold' + | '100' + | '200' + | '300' + | '400' + | '500' + | '600' + | '700' + | '800' + | '900'; + + /** + * 배경 색상 + * @default 'white' + */ + backgroundColor?: ColorValue; + + /** + * 둥근 모서리 반경 (픽셀) + * @default 5 + */ + borderRadius?: number; + + /** + * 테두리 두께 (픽셀) + * @default 1 + */ + borderWidth?: number; + + /** + * 테두리 색상 + * @default '#ccc' + */ + borderColor?: ColorValue; + + /** + * 수평 내부 여백 (픽셀, 좌우) + * @default 10 + */ + paddingHorizontal?: number; + + /** + * 수직 내부 여백 (픽셀, 상하) + * @default 10 + */ + paddingVertical?: number; +} + +/** + * 네이버 지도에 InfoWindow를 표시하는 컴포넌트입니다. + * + * InfoWindow는 마커의 위 또는 지도의 특정 지점에 부가적인 정보를 나타내기 위한 오버레이입니다. + * 주로 말풍선 형태로 구성되어 텍스트를 표시하는 용도로 사용합니다. + * + * **플랫폼별 스타일 지원:** + * - Android: 모든 스타일 속성 지원 (ViewAdapter를 통한 커스텀 뷰 렌더링) + * - iOS: 모든 스타일 속성 지원 (NMFOverlayImageDataSource를 통한 커스텀 이미지 렌더링) + * + * @example + * ```tsx + * // 1. 마커에 연결된 InfoWindow (권장) + * + * + * + * // 2. 특정 좌표에 InfoWindow 직접 표시 + * + * ``` + * + * @see https://navermaps.github.io/android-map-sdk/guide-ko/5-3.html + * @see https://navermaps.github.io/ios-map-sdk/guide-ko/5-3.html + */ +export const NaverMapInfoWindow = ({ + latitude, + longitude, + zIndex = 0, + globalZIndex = Const.NULL_NUMBER, + isHidden = false, + minZoom = Const.MIN_ZOOM, + maxZoom = Const.MAX_ZOOM, + isMinZoomInclusive = true, + isMaxZoomInclusive = true, + + identifier, + isOpen = true, + align = 'Top', + anchor = { x: 0.5, y: 1 }, + offsetX = 0, + offsetY = 0, + alpha = 1, + + text, + textSize = 14, + textColor = 'black', + fontWeight = 'normal', + backgroundColor = 'white', + borderRadius = 5, + borderWidth = 1, + borderColor = '#ccc', + paddingHorizontal = 10, + paddingVertical = 10, + + children, +}: NaverMapInfoWindowProps) => { + const fontWeightValue = (() => { + if (fontWeight === 'normal') return 400; + if (fontWeight === 'bold') return 700; + return parseInt(fontWeight, 10); + })(); + return ( + + {children} + + ); +}; diff --git a/src/component/NaverMapMarkerOverlay.tsx b/src/component/NaverMapMarkerOverlay.tsx index 5ea55d74..f9989753 100644 --- a/src/component/NaverMapMarkerOverlay.tsx +++ b/src/component/NaverMapMarkerOverlay.tsx @@ -140,6 +140,11 @@ export interface NaverMapMarkerOverlayProps extends BaseOverlayProps, Coord, PropsWithChildren<{}> { + /** + * 마커의 고유 식별자입니다. + * InfoWindow를 이 마커에 연결할 때 사용됩니다. + */ + identifier?: string; /** * 마커의 너비입니다. * @@ -322,6 +327,7 @@ export const NaverMapMarkerOverlay = ({ isMinZoomInclusive, isMaxZoomInclusive, + identifier, width = Const.NULL_NUMBER, height = Const.NULL_NUMBER, @@ -344,7 +350,9 @@ export const NaverMapMarkerOverlay = ({ }: NaverMapMarkerOverlayProps) => { nAssert( Children.count(children) <= 1, - `[NaverMapMarkerOverlay] children count should be equal or less than 1, is ${Children.count(children)}` + `[NaverMapMarkerOverlay] children count should be equal or less than 1, is ${Children.count( + children + )}` ); const _caption: NativeCaptionProp = (() => { @@ -379,6 +387,7 @@ export const NaverMapMarkerOverlay = ({ return ( ; + minZoom: Double; + maxZoom: Double; + isMinZoomInclusive?: WithDefault; + isMaxZoomInclusive?: WithDefault; +} + +//////////////////// + +interface Props extends BaseOverlay, ViewProps { + /** + * InfoWindow 위치 좌표 + * identifier가 없으면 이 좌표에 직접 표시 + * identifier가 있으면 대체 위치로 사용 + */ + coord: Readonly<{ + latitude: Double; + longitude: Double; + }>; + /** + * InfoWindow가 열릴 마커의 identifier + * 지정하면 해당 마커 위에 열림 + */ + identifier?: string; + /** + * InfoWindow를 처음부터 열린 상태로 표시할지 여부 + * @default true + */ + isOpen?: WithDefault; + /** + * InfoWindow가 마커에 대해 열릴 때의 정렬 방향 + * Top = 0, TopLeft = 1, TopRight = 2, Right = 3, + * BottomRight = 4, Bottom = 5, BottomLeft = 6, Left = 7, Center = 8 + * @default 0 (Top) + */ + align?: WithDefault; + /** + * 앵커 포인트 (0~1 범위의 비율) + * 왼쪽 위가 (0, 0), 오른쪽 아래가 (1, 1) + * @default {x: 0.5, y: 1} + */ + anchor?: Readonly<{ x: Double; y: Double }>; + /** + * X축 오프셋 (픽셀) + * @default 0 + */ + offsetX?: WithDefault; + /** + * Y축 오프셋 (픽셀) + * @default 0 + */ + offsetY?: WithDefault; + /** + * 불투명도 (0~1) + * @default 1 + */ + alpha?: WithDefault; + /** + * 텍스트 내용 + */ + text?: string; + /** + * 텍스트 크기 + * @default 14 + */ + textSize?: WithDefault; + /** + * 텍스트 색상 + */ + textColor?: Int32; + /** + * 폰트 굵기 (100-900, 400=normal, 700=bold) + * @default 400 + */ + fontWeight?: WithDefault; + /** + * 배경 색상 + */ + infoWindowBackgroundColor?: Int32; + /** + * 둥근 모서리 반경 (픽셀) + * @default 5 + */ + infoWindowBorderRadius?: WithDefault; + /** + * 테두리 두께 (픽셀) + * @default 1 + */ + infoWindowBorderWidth?: WithDefault; + /** + * 테두리 색상 + */ + infoWindowBorderColor?: Int32; + /** + * 수평 내부 여백 (픽셀) + * @default 10 + */ + infoWindowPaddingHorizontal?: WithDefault; + /** + * 수직 내부 여백 (픽셀) + * @default 10 + */ + infoWindowPaddingVertical?: WithDefault; +} + +export default codegenNativeComponent('RNCNaverMapInfoWindow'); diff --git a/src/spec/RNCNaverMapMarkerNativeComponent.ts b/src/spec/RNCNaverMapMarkerNativeComponent.ts index b3a9e653..0370f3b4 100644 --- a/src/spec/RNCNaverMapMarkerNativeComponent.ts +++ b/src/spec/RNCNaverMapMarkerNativeComponent.ts @@ -55,6 +55,10 @@ export type NativeImageProp = Readonly<{ //////////////////// interface Props extends BaseOverlay, ViewProps { + /** + * 마커의 고유 식별자 (InfoWindow 연결용) + */ + identifier?: string; coord: Readonly<{ latitude: Double; longitude: Double;