From 1a0447438c7e52934bbc7e4de1d33f1fb494f8cf Mon Sep 17 00:00:00 2001 From: ko-devHong Date: Wed, 1 Oct 2025 14:16:03 +0900 Subject: [PATCH 1/8] feat: implement InfoWindow component for Naver Map --- .../infowindow-implementation.md | 144 ++++++++++++++ .../reactnativenavermap/RNCNaverMapPackage.kt | 2 + .../infowindow/RNCNaverMapInfoWindow.kt | 104 ++++++++++ .../RNCNaverMapInfoWindowManager.kt | 187 ++++++++++++++++++ .../RNCNaverMapInfoWindowManagerSpec.kt | 19 ++ .../InfoWindow/RNCNaverMapInfoWindow.h | 27 +++ .../InfoWindow/RNCNaverMapInfoWindow.mm | 178 +++++++++++++++++ ios/RNCNaverMapViewImpl.h | 1 + ios/RNCNaverMapViewImpl.mm | 3 + src/component/NaverMapInfoWindow.tsx | 156 +++++++++++++++ src/index.tsx | 4 + .../RNCNaverMapInfoWindowNativeComponent.ts | 84 ++++++++ 12 files changed, 909 insertions(+) create mode 100644 .claude/implementations/infowindow-implementation.md create mode 100644 android/src/main/java/com/mjstudio/reactnativenavermap/overlay/infowindow/RNCNaverMapInfoWindow.kt create mode 100644 android/src/main/java/com/mjstudio/reactnativenavermap/overlay/infowindow/RNCNaverMapInfoWindowManager.kt create mode 100644 android/src/newarch/RNCNaverMapInfoWindowManagerSpec.kt create mode 100644 ios/Overlay/InfoWindow/RNCNaverMapInfoWindow.h create mode 100644 ios/Overlay/InfoWindow/RNCNaverMapInfoWindow.mm create mode 100644 src/component/NaverMapInfoWindow.tsx create mode 100644 src/spec/RNCNaverMapInfoWindowNativeComponent.ts diff --git a/.claude/implementations/infowindow-implementation.md b/.claude/implementations/infowindow-implementation.md new file mode 100644 index 00000000..efaee64a --- /dev/null +++ b/.claude/implementations/infowindow-implementation.md @@ -0,0 +1,144 @@ +# 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/` + +- `NMFInfoWindow` 래핑 +- 커스텀 어댑터 패턴 구현 (`RNCNaverMapInfoWindowAdapter`) +- 텍스트 및 스타일 동적 업데이트 +- 마커 연결 지원 (예정) + +#### 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 업데이트 처리 +- `dataSource` 블록을 통한 커스텀 뷰 생성 +- UILabel 기반 텍스트 렌더링 +- 스타일링 (테두리, 라운드 코너, 패딩) + +### 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/maps.js.ncp/docs/naver.maps.InfoWindow.html) + +## 향후 개선 사항 + +1. **마커 연결 기능**: `markerTag`를 통한 마커 찾기 및 연결 +2. **커스텀 뷰 지원**: React 자식 컴포넌트를 InfoWindow 콘텐츠로 사용 +3. **더 많은 스타일 옵션**: 테두리 색상, 화살표 표시 등 +4. **애니메이션**: 열기/닫기 애니메이션 +5. **이벤트 확장**: `onOpen`, `onTap`, `onClose` 이벤트 등 + +## 구현 패턴 + +이 구현은 다음 패턴을 따릅니다: + +- **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 추가 +- ⏳ 마커 연결 기능 (향후) +- ⏳ 커스텀 뷰 지원 (향후) + 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/overlay/infowindow/RNCNaverMapInfoWindow.kt b/android/src/main/java/com/mjstudio/reactnativenavermap/overlay/infowindow/RNCNaverMapInfoWindow.kt new file mode 100644 index 00000000..f959493a --- /dev/null +++ b/android/src/main/java/com/mjstudio/reactnativenavermap/overlay/infowindow/RNCNaverMapInfoWindow.kt @@ -0,0 +1,104 @@ +package com.mjstudio.reactnativenavermap.overlay.infowindow + +import android.annotation.SuppressLint +import android.view.LayoutInflater +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 + + override val overlay: InfoWindow by lazy { + InfoWindow().apply { + customAdapter = RNCNaverMapInfoWindowAdapter(reactContext) + adapter = customAdapter as InfoWindow.Adapter + } + } + + override fun addToMap(map: NaverMap) { + val marker = targetMarker + if (marker != null) { + overlay.open(marker) + } else { + val pos = position + if (pos != null) { + overlay.position = pos + overlay.map = map + } + } + } + + override fun removeFromMap(map: NaverMap) { + overlay.close() + } + + override fun onDropViewInstance() { + overlay.close() + customAdapter = null + } + + fun setPosition(latLng: LatLng) { + position = latLng + overlay.position = latLng + } + + fun setMarker(marker: Marker?) { + targetMarker = marker + } + + 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 setInfoWindowBackgroundColor(color: Int) { + customAdapter?.backgroundColor = color + 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 backgroundColor: Int = android.graphics.Color.WHITE + + override fun getView(infoWindow: InfoWindow): View { + val view = LayoutInflater.from(context).inflate( + android.R.layout.simple_list_item_1, + null, + ) + val textView = view.findViewById(android.R.id.text1) + + textView.text = text ?: "" + textView.textSize = textSize + textView.setTextColor(textColor) + view.setBackgroundColor(backgroundColor) + + return view + } + } +} + 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..d058fd57 --- /dev/null +++ b/android/src/main/java/com/mjstudio/reactnativenavermap/overlay/infowindow/RNCNaverMapInfoWindowManager.kt @@ -0,0 +1,187 @@ +package com.mjstudio.reactnativenavermap.overlay.infowindow + +import android.graphics.PointF +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.getAlign +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 = "infoWindowBackgroundColor") + override fun setInfoWindowBackgroundColor( + view: RNCNaverMapInfoWindow?, + value: Int, + ) { + view?.setInfoWindowBackgroundColor(value) + } + + @ReactProp(name = "markerTag") + override fun setMarkerTag( + view: RNCNaverMapInfoWindow?, + value: String?, + ) { + // This will need to be handled by finding the marker by tag + // For now, we'll leave it for future implementation + } + + companion object { + const val NAME = "RNCNaverMapInfoWindow" + } +} + diff --git a/android/src/newarch/RNCNaverMapInfoWindowManagerSpec.kt b/android/src/newarch/RNCNaverMapInfoWindowManagerSpec.kt new file mode 100644 index 00000000..bf90c592 --- /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/ios/Overlay/InfoWindow/RNCNaverMapInfoWindow.h b/ios/Overlay/InfoWindow/RNCNaverMapInfoWindow.h new file mode 100644 index 00000000..87684603 --- /dev/null +++ b/ios/Overlay/InfoWindow/RNCNaverMapInfoWindow.h @@ -0,0 +1,27 @@ +// +// 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 + +@interface RNCNaverMapInfoWindow : RCTViewComponentView +@property(nonatomic, strong) NMFInfoWindow* inner; +@end + diff --git a/ios/Overlay/InfoWindow/RNCNaverMapInfoWindow.mm b/ios/Overlay/InfoWindow/RNCNaverMapInfoWindow.mm new file mode 100644 index 00000000..fdb91866 --- /dev/null +++ b/ios/Overlay/InfoWindow/RNCNaverMapInfoWindow.mm @@ -0,0 +1,178 @@ +// +// RNCNaverMapInfoWindow.mm +// mj-studio-react-native-naver-map +// +// Created by AI Assistant +// + +#import "RNCNaverMapInfoWindow.h" +#import + +using namespace facebook::react; + +@interface RNCNaverMapInfoWindow () + +@end + +@implementation RNCNaverMapInfoWindow + +- (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]; + } + + return self; +} + +- (instancetype)initWithFrame:(CGRect)frame { + if (self = [super initWithFrame:frame]) { + static const auto defaultProps = std::make_shared(); + _props = defaultProps; + } + + return self; +} + +- (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); + + 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; + + // Text content and styling + if (prev.text != next.text) { + NSString* text = getNsStr(next.text); + + // Create a simple attributed string with styling + NSMutableParagraphStyle* paragraphStyle = [[NSMutableParagraphStyle alloc] init]; + paragraphStyle.alignment = NSTextAlignmentCenter; + + UIColor* textColor = nmap::intToColor(next.textColor); + UIColor* bgColor = nmap::intToColor(next.infoWindowBackgroundColor); + CGFloat textSize = next.textSize; + + NSDictionary* attributes = @{ + NSFontAttributeName : [UIFont systemFontOfSize:textSize], + NSForegroundColorAttributeName : textColor, + NSBackgroundColorAttributeName : bgColor, + NSParagraphStyleAttributeName : paragraphStyle + }; + + NSAttributedString* attributedText = [[NSAttributedString alloc] initWithString:text attributes:attributes]; + + // Create a label to display the text + UILabel* label = [[UILabel alloc] init]; + label.attributedText = attributedText; + label.numberOfLines = 0; + label.textAlignment = NSTextAlignmentCenter; + + // Add padding + CGSize textSize_size = [attributedText size]; + CGFloat padding = 10.0; + label.frame = CGRectMake(0, 0, textSize_size.width + padding * 2, textSize_size.height + padding * 2); + + // Set rounded corners and border + label.layer.cornerRadius = 5.0; + label.layer.masksToBounds = YES; + label.layer.borderWidth = 1.0; + label.layer.borderColor = [UIColor lightGrayColor].CGColor; + + // Create a custom view adapter + _inner.dataSource = ^UIView* _Nullable(NMFInfoWindow* infoWindow) { + return label; + }; + } + + // Update colors and sizes even if text didn't change + if (prev.textColor != next.textColor || prev.infoWindowBackgroundColor != next.infoWindowBackgroundColor || + prev.textSize != next.textSize) { + // Trigger update by re-setting the text + if (next.text.length() > 0) { + NSString* text = getNsStr(next.text); + + NSMutableParagraphStyle* paragraphStyle = [[NSMutableParagraphStyle alloc] init]; + paragraphStyle.alignment = NSTextAlignmentCenter; + + UIColor* textColor = nmap::intToColor(next.textColor); + UIColor* bgColor = nmap::intToColor(next.infoWindowBackgroundColor); + CGFloat textSize = next.textSize; + + NSDictionary* attributes = @{ + NSFontAttributeName : [UIFont systemFontOfSize:textSize], + NSForegroundColorAttributeName : textColor, + NSBackgroundColorAttributeName : bgColor, + NSParagraphStyleAttributeName : paragraphStyle + }; + + NSAttributedString* attributedText = [[NSAttributedString alloc] initWithString:text attributes:attributes]; + + UILabel* label = [[UILabel alloc] init]; + label.attributedText = attributedText; + label.numberOfLines = 0; + label.textAlignment = NSTextAlignmentCenter; + + CGSize textSize_size = [attributedText size]; + CGFloat padding = 10.0; + label.frame = CGRectMake(0, 0, textSize_size.width + padding * 2, textSize_size.height + padding * 2); + + label.layer.cornerRadius = 5.0; + label.layer.masksToBounds = YES; + label.layer.borderWidth = 1.0; + label.layer.borderColor = [UIColor lightGrayColor].CGColor; + + _inner.dataSource = ^UIView* _Nullable(NMFInfoWindow* infoWindow) { + return label; + }; + } + } + + [super updateProps:props oldProps:oldProps]; +} + +Class RNCNaverMapInfoWindowCls(void) { + return RNCNaverMapInfoWindow.class; +} + ++ (ComponentDescriptorProvider)componentDescriptorProvider { + return concreteComponentDescriptorProvider(); +} + +@end + diff --git a/ios/RNCNaverMapViewImpl.h b/ios/RNCNaverMapViewImpl.h index 551ba899..6a798354 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" diff --git a/ios/RNCNaverMapViewImpl.mm b/ios/RNCNaverMapViewImpl.mm index 76d8f28b..5da7302a 100644 --- a/ios/RNCNaverMapViewImpl.mm +++ b/ios/RNCNaverMapViewImpl.mm @@ -113,6 +113,9 @@ - (void)insertReactSubview:(id)subview atIndex:(NSInteger)atIndex marker.overlayImage = NMF_MARKER_IMAGE_GREEN; } marker.mapView = self.mapView; + } else if ([subview isKindOfClass:[RNCNaverMapInfoWindow class]]) { + auto infoWindow = static_cast(subview).inner; + infoWindow.mapView = self.mapView; } else { NSArray>* childSubviews = [subview reactSubviews]; for (int i = 0; i < childSubviews.count; i++) { diff --git a/src/component/NaverMapInfoWindow.tsx b/src/component/NaverMapInfoWindow.tsx new file mode 100644 index 00000000..cb655034 --- /dev/null +++ b/src/component/NaverMapInfoWindow.tsx @@ -0,0 +1,156 @@ +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가 열릴 마커의 태그 (선택적) + * 지정하면 해당 마커 위에 InfoWindow가 열립니다. + * 지정하지 않으면 latitude, longitude 좌표에 InfoWindow가 열립니다. + */ + markerTag?: string; + + /** + * 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; + + /** + * 배경 색상 + * @default 'white' + */ + backgroundColor?: ColorValue; +} + +/** + * 네이버 지도에 InfoWindow를 표시하는 컴포넌트입니다. + * + * InfoWindow는 마커의 위 또는 지도의 특정 지점에 부가적인 정보를 나타내기 위한 오버레이입니다. + * 주로 말풍선 형태로 구성되어 텍스트를 표시하는 용도로 사용합니다. + * + * @example + * ```tsx + * // 특정 좌표에 InfoWindow 표시 + * + * + * // 마커에 연결된 InfoWindow + * + * ``` + * + * @see https://navermaps.github.io/android-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, + + markerTag, + align = 'Top', + anchor = { x: 0.5, y: 1 }, + offsetX = 0, + offsetY = 0, + alpha = 1, + + text, + textSize = 14, + textColor = 'black', + backgroundColor = 'white', + + children, +}: NaverMapInfoWindowProps) => { + return ( + + {children} + + ); +}; diff --git a/src/index.tsx b/src/index.tsx index 9d5bf167..4c07dfc3 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -12,6 +12,10 @@ export { NaverMapGroundOverlay, type NaverMapGroundOverlayProps, } from './component/NaverMapGroundOverlay'; +export { + NaverMapInfoWindow, + type NaverMapInfoWindowProps, +} from './component/NaverMapInfoWindow'; export { type CaptionType, NaverMapMarkerOverlay, diff --git a/src/spec/RNCNaverMapInfoWindowNativeComponent.ts b/src/spec/RNCNaverMapInfoWindowNativeComponent.ts new file mode 100644 index 00000000..bf0e2cca --- /dev/null +++ b/src/spec/RNCNaverMapInfoWindowNativeComponent.ts @@ -0,0 +1,84 @@ +import { codegenNativeComponent, type ViewProps } from 'react-native'; +import type { + Double, + Int32, + WithDefault, +} from 'react-native/Libraries/Types/CodegenTypes'; + +/* Type should be redeclared because of codegen ts parser doesn't allow imported type + * [comments](https://github.com/reactwg/react-native-new-architecture/discussions/91#discussioncomment-4282452) + */ + +interface BaseOverlay { + zIndexValue: Int32; + globalZIndexValue: Int32; + isHidden?: WithDefault; + minZoom: Double; + maxZoom: Double; + isMinZoomInclusive?: WithDefault; + isMaxZoomInclusive?: WithDefault; +} + +//////////////////// + +interface Props extends BaseOverlay, ViewProps { + /** + * InfoWindow 위치 좌표 + */ + coord: Readonly<{ + latitude: Double; + longitude: Double; + }>; + /** + * InfoWindow가 열릴 마커 ID (선택적) + * 지정하면 마커 위에 열림, 없으면 coord 위치에 열림 + */ + markerTag?: string; + /** + * 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; + /** + * 배경 색상 + */ + infoWindowBackgroundColor?: Int32; +} + +export default codegenNativeComponent('RNCNaverMapInfoWindow'); From 5ea018b4882795575138dd23391c11a2112485cd Mon Sep 17 00:00:00 2001 From: ko-devHong Date: Wed, 1 Oct 2025 17:02:18 +0900 Subject: [PATCH 2/8] feat: enhance InfoWindow functionality with marker identifier support but ios not finish --- .../mapview/RNCNaverMapView.kt | 21 +++ .../infowindow/RNCNaverMapInfoWindow.kt | 140 +++++++++++++--- .../RNCNaverMapInfoWindowManager.kt | 55 +++++- .../marker/RNCNaverMapMarkerManager.kt | 8 + .../InfoWindow/RNCNaverMapInfoWindow.h | 7 + .../InfoWindow/RNCNaverMapInfoWindow.mm | 157 ++++++++---------- ios/Overlay/Marker/RNCNaverMapMarker.mm | 5 + ios/RNCNaverMapViewImpl.h | 3 + ios/RNCNaverMapViewImpl.mm | 25 ++- package.json | 1 + src/component/NaverMapInfoWindow.tsx | 100 +++++++++-- src/component/NaverMapMarkerOverlay.tsx | 11 +- .../RNCNaverMapInfoWindowNativeComponent.ts | 37 ++++- src/spec/RNCNaverMapMarkerNativeComponent.ts | 4 + 14 files changed, 438 insertions(+), 136 deletions(-) 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..b714d404 100644 --- a/android/src/main/java/com/mjstudio/reactnativenavermap/mapview/RNCNaverMapView.kt +++ b/android/src/main/java/com/mjstudio/reactnativenavermap/mapview/RNCNaverMapView.kt @@ -34,6 +34,9 @@ class RNCNaverMapView( private var attacherGroup: ViewAttacherGroup? = null 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/overlay/infowindow/RNCNaverMapInfoWindow.kt b/android/src/main/java/com/mjstudio/reactnativenavermap/overlay/infowindow/RNCNaverMapInfoWindow.kt index f959493a..09723583 100644 --- a/android/src/main/java/com/mjstudio/reactnativenavermap/overlay/infowindow/RNCNaverMapInfoWindow.kt +++ b/android/src/main/java/com/mjstudio/reactnativenavermap/overlay/infowindow/RNCNaverMapInfoWindow.kt @@ -18,6 +18,10 @@ class RNCNaverMapInfoWindow( 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 { @@ -27,20 +31,44 @@ class RNCNaverMapInfoWindow( } override fun addToMap(map: NaverMap) { - val marker = targetMarker - if (marker != null) { - overlay.open(marker) - } else { - val pos = position - if (pos != null) { - overlay.position = pos - overlay.map = map - } - } + 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() { @@ -50,11 +78,17 @@ class RNCNaverMapInfoWindow( fun setPosition(latLng: LatLng) { position = latLng - overlay.position = latLng + updateInfoWindowState() } - fun setMarker(marker: Marker?) { - targetMarker = marker + fun setMarkerIdentifier(identifier: String?) { + markerIdentifier = identifier + updateInfoWindowState() + } + + fun setIsOpen(isOpen: Boolean) { + shouldBeOpen = isOpen + updateInfoWindowState() } fun setText(text: String?) { @@ -72,32 +106,92 @@ class RNCNaverMapInfoWindow( 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 setInfoWindowPadding(padding: Float) { + customAdapter?.padding = 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 padding: Float = 10f override fun getView(infoWindow: InfoWindow): View { - val view = LayoutInflater.from(context).inflate( - android.R.layout.simple_list_item_1, - null, - ) - val textView = view.findViewById(android.R.id.text1) + val paddingPx = this@RNCNaverMapInfoWindowAdapter.padding.toInt() - textView.text = text ?: "" - textView.textSize = textSize - textView.setTextColor(textColor) - view.setBackgroundColor(backgroundColor) + 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 + this.setPadding(paddingPx, paddingPx, paddingPx, paddingPx) + + // 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 view + 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 index d058fd57..2c597f65 100644 --- a/android/src/main/java/com/mjstudio/reactnativenavermap/overlay/infowindow/RNCNaverMapInfoWindowManager.kt +++ b/android/src/main/java/com/mjstudio/reactnativenavermap/overlay/infowindow/RNCNaverMapInfoWindowManager.kt @@ -163,6 +163,14 @@ class RNCNaverMapInfoWindowManager : RNCNaverMapInfoWindowManagerSpec 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/ios/Overlay/InfoWindow/RNCNaverMapInfoWindow.h b/ios/Overlay/InfoWindow/RNCNaverMapInfoWindow.h index 87684603..e15a4ed1 100644 --- a/ios/Overlay/InfoWindow/RNCNaverMapInfoWindow.h +++ b/ios/Overlay/InfoWindow/RNCNaverMapInfoWindow.h @@ -21,7 +21,14 @@ #import #import +@class RNCNaverMapViewImpl; +@class RNCNaverMapMarker; + @interface RNCNaverMapInfoWindow : RCTViewComponentView @property(nonatomic, strong) NMFInfoWindow* inner; + +- (void)setCurrentMapView:(NMFMapView*)mapView; +- (void)updateInfoWindowState; + @end diff --git a/ios/Overlay/InfoWindow/RNCNaverMapInfoWindow.mm b/ios/Overlay/InfoWindow/RNCNaverMapInfoWindow.mm index fdb91866..3273b0b6 100644 --- a/ios/Overlay/InfoWindow/RNCNaverMapInfoWindow.mm +++ b/ios/Overlay/InfoWindow/RNCNaverMapInfoWindow.mm @@ -6,6 +6,8 @@ // #import "RNCNaverMapInfoWindow.h" +#import "RNCNaverMapViewImpl.h" +#import "RNCNaverMapMarker.h" #import using namespace facebook::react; @@ -14,7 +16,12 @@ @interface RNCNaverMapInfoWindow () @end -@implementation RNCNaverMapInfoWindow +@implementation RNCNaverMapInfoWindow { + NSString* _markerIdentifier; + BOOL _shouldBeOpen; + NMFMapView* _currentMapView; + NMFInfoWindowDefaultTextSource* _textDataSource; +} - (RCTBridge*)bridge { return [RCTBridge currentBridge]; @@ -29,6 +36,12 @@ - (RCTBridge*)bridge { - (instancetype)init { if ((self = [super init])) { _inner = [NMFInfoWindow new]; + _shouldBeOpen = YES; // Default isOpen = true + + // Create text data source + _textDataSource = [NMFInfoWindowDefaultTextSource dataSource]; + _textDataSource.title = @""; + _inner.dataSource = _textDataSource; } return self; @@ -43,12 +56,55 @@ - (instancetype)initWithFrame:(CGRect)frame { return self; } +- (void)setCurrentMapView:(NMFMapView*)mapView { + _currentMapView = mapView; + [self updateInfoWindowState]; +} + +- (RNCNaverMapViewImpl*)findMapView { + UIView* current = self.superview; + while (current) { + if ([current isKindOfClass:[RNCNaverMapViewImpl class]]) { + return (RNCNaverMapViewImpl*)current; + } + current = current.superview; + } + return nil; +} + +- (void)updateInfoWindowState { + if (!_shouldBeOpen) { + [_inner close]; + return; + } + + if (!_currentMapView) return; + + // Try to find marker by identifier first + if (_markerIdentifier && _markerIdentifier.length > 0) { + RNCNaverMapViewImpl* mapViewImpl = [self findMapView]; + if (mapViewImpl) { + RNCNaverMapMarker* markerView = mapViewImpl.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)) + if (!nmap::isCoordEqual(prev.coord, next.coord)) { _inner.position = nmap::createLatLng(next.coord); + [self updateInfoWindowState]; + } if (prev.zIndexValue != next.zIndexValue) _inner.zIndex = next.zIndexValue; @@ -75,93 +131,24 @@ - (void)updateProps:(Props::Shared const&)props oldProps:(Props::Shared const&)o 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 + // Text content - use simple text source for now if (prev.text != next.text) { - NSString* text = getNsStr(next.text); - - // Create a simple attributed string with styling - NSMutableParagraphStyle* paragraphStyle = [[NSMutableParagraphStyle alloc] init]; - paragraphStyle.alignment = NSTextAlignmentCenter; - - UIColor* textColor = nmap::intToColor(next.textColor); - UIColor* bgColor = nmap::intToColor(next.infoWindowBackgroundColor); - CGFloat textSize = next.textSize; - - NSDictionary* attributes = @{ - NSFontAttributeName : [UIFont systemFontOfSize:textSize], - NSForegroundColorAttributeName : textColor, - NSBackgroundColorAttributeName : bgColor, - NSParagraphStyleAttributeName : paragraphStyle - }; - - NSAttributedString* attributedText = [[NSAttributedString alloc] initWithString:text attributes:attributes]; - - // Create a label to display the text - UILabel* label = [[UILabel alloc] init]; - label.attributedText = attributedText; - label.numberOfLines = 0; - label.textAlignment = NSTextAlignmentCenter; - - // Add padding - CGSize textSize_size = [attributedText size]; - CGFloat padding = 10.0; - label.frame = CGRectMake(0, 0, textSize_size.width + padding * 2, textSize_size.height + padding * 2); - - // Set rounded corners and border - label.layer.cornerRadius = 5.0; - label.layer.masksToBounds = YES; - label.layer.borderWidth = 1.0; - label.layer.borderColor = [UIColor lightGrayColor].CGColor; - - // Create a custom view adapter - _inner.dataSource = ^UIView* _Nullable(NMFInfoWindow* infoWindow) { - return label; - }; + _textDataSource.title = getNsStr(next.text); } - // Update colors and sizes even if text didn't change - if (prev.textColor != next.textColor || prev.infoWindowBackgroundColor != next.infoWindowBackgroundColor || - prev.textSize != next.textSize) { - // Trigger update by re-setting the text - if (next.text.length() > 0) { - NSString* text = getNsStr(next.text); - - NSMutableParagraphStyle* paragraphStyle = [[NSMutableParagraphStyle alloc] init]; - paragraphStyle.alignment = NSTextAlignmentCenter; - - UIColor* textColor = nmap::intToColor(next.textColor); - UIColor* bgColor = nmap::intToColor(next.infoWindowBackgroundColor); - CGFloat textSize = next.textSize; - - NSDictionary* attributes = @{ - NSFontAttributeName : [UIFont systemFontOfSize:textSize], - NSForegroundColorAttributeName : textColor, - NSBackgroundColorAttributeName : bgColor, - NSParagraphStyleAttributeName : paragraphStyle - }; - - NSAttributedString* attributedText = [[NSAttributedString alloc] initWithString:text attributes:attributes]; - - UILabel* label = [[UILabel alloc] init]; - label.attributedText = attributedText; - label.numberOfLines = 0; - label.textAlignment = NSTextAlignmentCenter; - - CGSize textSize_size = [attributedText size]; - CGFloat padding = 10.0; - label.frame = CGRectMake(0, 0, textSize_size.width + padding * 2, textSize_size.height + padding * 2); - - label.layer.cornerRadius = 5.0; - label.layer.masksToBounds = YES; - label.layer.borderWidth = 1.0; - label.layer.borderColor = [UIColor lightGrayColor].CGColor; - - _inner.dataSource = ^UIView* _Nullable(NMFInfoWindow* infoWindow) { - return label; - }; - } - } [super updateProps:props oldProps:oldProps]; } diff --git a/ios/Overlay/Marker/RNCNaverMapMarker.mm b/ios/Overlay/Marker/RNCNaverMapMarker.mm index fa122094..72b04155 100644 --- a/ios/Overlay/Marker/RNCNaverMapMarker.mm +++ b/ios/Overlay/Marker/RNCNaverMapMarker.mm @@ -227,6 +227,11 @@ - (void)updateProps:(Props::Shared const&)props oldProps:(Props::Shared const&)o _inner.subCaptionMinZoom = caption.minZoom; _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]; diff --git a/ios/RNCNaverMapViewImpl.h b/ios/RNCNaverMapViewImpl.h index 6a798354..739263fb 100644 --- a/ios/RNCNaverMapViewImpl.h +++ b/ios/RNCNaverMapViewImpl.h @@ -48,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 5da7302a..1cde6ba9 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; @@ -114,8 +123,8 @@ - (void)insertReactSubview:(id)subview atIndex:(NSInteger)atIndex } marker.mapView = self.mapView; } else if ([subview isKindOfClass:[RNCNaverMapInfoWindow class]]) { - auto infoWindow = static_cast(subview).inner; - infoWindow.mapView = self.mapView; + auto infoWindowView = static_cast(subview); + [infoWindowView setCurrentMapView:self.mapView]; } else { NSArray>* childSubviews = [subview reactSubviews]; for (int i = 0; i < childSubviews.count; i++) { @@ -129,7 +138,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 index cb655034..d81f46d7 100644 --- a/src/component/NaverMapInfoWindow.tsx +++ b/src/component/NaverMapInfoWindow.tsx @@ -13,11 +13,17 @@ export interface NaverMapInfoWindowProps Coord, PropsWithChildren<{}> { /** - * InfoWindow가 열릴 마커의 태그 (선택적) - * 지정하면 해당 마커 위에 InfoWindow가 열립니다. - * 지정하지 않으면 latitude, longitude 좌표에 InfoWindow가 열립니다. + * InfoWindow가 열릴 마커의 identifier + * 지정하면 해당 identifier를 가진 마커 위에 InfoWindow가 열립니다. + * identifier가 없으면 latitude, longitude 좌표에 직접 표시됩니다. */ - markerTag?: string; + identifier?: string; + + /** + * InfoWindow를 처음부터 열린 상태로 표시할지 여부 + * @default true + */ + isOpen?: boolean; /** * InfoWindow가 마커에 대해 열릴 때의 정렬 방향 @@ -67,11 +73,53 @@ export interface NaverMapInfoWindowProps */ 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 + */ + padding?: number; } /** @@ -82,23 +130,26 @@ export interface NaverMapInfoWindowProps * * @example * ```tsx - * // 특정 좌표에 InfoWindow 표시 - * + * * - * // 마커에 연결된 InfoWindow + * // 2. 특정 좌표에 InfoWindow 직접 표시 * * ``` * @@ -115,7 +166,8 @@ export const NaverMapInfoWindow = ({ isMinZoomInclusive = true, isMaxZoomInclusive = true, - markerTag, + identifier, + isOpen = true, align = 'Top', anchor = { x: 0.5, y: 1 }, offsetX = 0, @@ -125,10 +177,20 @@ export const NaverMapInfoWindow = ({ text, textSize = 14, textColor = 'black', + fontWeight = 'normal', backgroundColor = 'white', + borderRadius = 5, + borderWidth = 1, + borderColor = '#ccc', + padding = 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 ( ; /** - * InfoWindow가 열릴 마커 ID (선택적) - * 지정하면 마커 위에 열림, 없으면 coord 위치에 열림 + * InfoWindow가 열릴 마커의 identifier + * 지정하면 해당 마커 위에 열림 */ - markerTag?: string; + identifier?: string; + /** + * InfoWindow를 처음부터 열린 상태로 표시할지 여부 + * @default true + */ + isOpen?: WithDefault; /** * InfoWindow가 마커에 대해 열릴 때의 정렬 방향 * Top = 0, TopLeft = 1, TopRight = 2, Right = 3, @@ -75,10 +82,34 @@ interface Props extends BaseOverlay, ViewProps { * 텍스트 색상 */ 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 + */ + infoWindowPadding?: 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; From b268b67f466ed3c823baf12c3ed9e7ab36df5e9b Mon Sep 17 00:00:00 2001 From: ko-devHong Date: Wed, 1 Oct 2025 18:03:30 +0900 Subject: [PATCH 3/8] feat: enhance InfoWindow with platform-specific styling support and improved marker integration --- .../infowindow-implementation.md | 105 +++++++++++++++--- .../InfoWindow/RNCNaverMapInfoWindow.h | 1 + .../InfoWindow/RNCNaverMapInfoWindow.mm | 38 +++---- ios/RNCNaverMapViewImpl.mm | 1 + src/component/NaverMapInfoWindow.tsx | 9 ++ 5 files changed, 120 insertions(+), 34 deletions(-) diff --git a/.claude/implementations/infowindow-implementation.md b/.claude/implementations/infowindow-implementation.md index efaee64a..927706e2 100644 --- a/.claude/implementations/infowindow-implementation.md +++ b/.claude/implementations/infowindow-implementation.md @@ -90,13 +90,22 @@ import { NaverMapInfoWindow } from '@mj-studio/react-native-naver-map'; backgroundColor="white" /> -// 마커에 연결된 InfoWindow (향후 구현) +// 마커에 연결된 InfoWindow + ``` @@ -105,13 +114,75 @@ import { NaverMapInfoWindow } from '@mj-studio/react-native-naver-map'; - [Android InfoWindow 공식 문서](https://navermaps.github.io/android-map-sdk/guide-ko/5-3.html) - [iOS NMFInfoWindow API](https://navermaps.github.io/maps.js.ncp/docs/naver.maps.InfoWindow.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` - 텍스트 내용 +- ✅ 마커 연결 (`identifier`) +- ✅ 열림/닫힘 제어 (`isOpen`) +- ❌ `textSize`, `textColor` - 무시됨 +- ❌ `fontWeight`, `borderRadius`, `borderWidth`, `borderColor`, `padding` - 무시됨 + +**제한 이유:** +iOS의 `NMFInfoWindow`는 기본적으로 `NMFInfoWindowDefaultTextSource`를 사용하며, 이는 말풍선 스타일의 텍스트만 표시합니다. -1. **마커 연결 기능**: `markerTag`를 통한 마커 찾기 및 연결 -2. **커스텀 뷰 지원**: React 자식 컴포넌트를 InfoWindow 콘텐츠로 사용 -3. **더 많은 스타일 옵션**: 테두리 색상, 화살표 표시 등 -4. **애니메이션**: 열기/닫기 애니메이션 -5. **이벤트 확장**: `onOpen`, `onTap`, `onClose` 이벤트 등 +커스텀 스타일을 위해 `NMFOverlayImageDataSource`를 시도했으나: +- `NMFInfoWindow`가 내부적으로 `toUIImage` 메서드 호출 (존재하지 않음) +- 일반 오버레이와 달리 InfoWindow는 이미지 기반 커스터마이징 미지원 +- 구현 시도 시 에러 발생 내용: `-[NMFOverlayImage toUIImage]: unrecognized selector` + +### iOS에서 커스텀 스타일이 필요한 경우 + +**Option 1: Marker의 Custom View 사용** +```tsx + + + 커스텀 정보 + + +``` + +**Option 2: 플랫폼별 조건부 렌더링** +```tsx +{Platform.OS === 'android' ? ( + +) : ( + + + +)} +``` ## 구현 패턴 @@ -134,11 +205,19 @@ import { NaverMapInfoWindow } from '@mj-studio/react-native-naver-map'; ## 완료 상태 - ✅ TypeScript Spec 및 타입 정의 -- ✅ Android 네이티브 구현 -- ✅ iOS 네이티브 구현 +- ✅ Android 네이티브 구현 (모든 스타일 지원) +- ✅ iOS 네이티브 구현 (기본 텍스트) - ✅ React Component 작성 - ✅ Package 등록 - ✅ Export 추가 -- ⏳ 마커 연결 기능 (향후) -- ⏳ 커스텀 뷰 지원 (향후) +- ✅ 마커 연결 기능 (`identifier`) +- ✅ 열림/닫힘 제어 (`isOpen`) +- ✅ Marker Registry 구현 +- ⚠️ iOS 커스텀 스타일 (API 제한으로 미지원) + +## 사용 권장사항 + +- **간단한 텍스트만 필요**: InfoWindow 사용 (양쪽 플랫폼) +- **커스텀 스타일 필요 (Android만)**: InfoWindow 사용, iOS는 기본 스타일 +- **커스텀 스타일 필요 (양쪽 플랫폼)**: Marker의 Custom View 사용 diff --git a/ios/Overlay/InfoWindow/RNCNaverMapInfoWindow.h b/ios/Overlay/InfoWindow/RNCNaverMapInfoWindow.h index e15a4ed1..345b1595 100644 --- a/ios/Overlay/InfoWindow/RNCNaverMapInfoWindow.h +++ b/ios/Overlay/InfoWindow/RNCNaverMapInfoWindow.h @@ -28,6 +28,7 @@ @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 index 3273b0b6..e123bebe 100644 --- a/ios/Overlay/InfoWindow/RNCNaverMapInfoWindow.mm +++ b/ios/Overlay/InfoWindow/RNCNaverMapInfoWindow.mm @@ -20,6 +20,7 @@ @implementation RNCNaverMapInfoWindow { NSString* _markerIdentifier; BOOL _shouldBeOpen; NMFMapView* _currentMapView; + RNCNaverMapViewImpl* _parentMapViewImpl; NMFInfoWindowDefaultTextSource* _textDataSource; } @@ -38,7 +39,7 @@ - (instancetype)init { _inner = [NMFInfoWindow new]; _shouldBeOpen = YES; // Default isOpen = true - // Create text data source + // Create text data source (iOS only supports text for now) _textDataSource = [NMFInfoWindowDefaultTextSource dataSource]; _textDataSource.title = @""; _inner.dataSource = _textDataSource; @@ -61,15 +62,9 @@ - (void)setCurrentMapView:(NMFMapView*)mapView { [self updateInfoWindowState]; } -- (RNCNaverMapViewImpl*)findMapView { - UIView* current = self.superview; - while (current) { - if ([current isKindOfClass:[RNCNaverMapViewImpl class]]) { - return (RNCNaverMapViewImpl*)current; - } - current = current.superview; - } - return nil; +- (void)setParentMapViewImpl:(RNCNaverMapViewImpl*)mapViewImpl { + _parentMapViewImpl = mapViewImpl; + [self updateInfoWindowState]; } - (void)updateInfoWindowState { @@ -81,15 +76,12 @@ - (void)updateInfoWindowState { if (!_currentMapView) return; // Try to find marker by identifier first - if (_markerIdentifier && _markerIdentifier.length > 0) { - RNCNaverMapViewImpl* mapViewImpl = [self findMapView]; - if (mapViewImpl) { - RNCNaverMapMarker* markerView = mapViewImpl.markerRegistry[_markerIdentifier]; - if (markerView) { - // Open on marker (marker position is used automatically) - [_inner openWithMarker:markerView.inner]; - return; - } + 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; } } @@ -144,11 +136,15 @@ - (void)updateProps:(Props::Shared const&)props oldProps:(Props::Shared const&)o [self updateInfoWindowState]; } - // Text content - use simple text source for now + // Text content only (iOS custom styling is not supported by NMFInfoWindow API) + // For custom styling on iOS, consider using Marker with custom view instead if (prev.text != next.text) { _textDataSource.title = getNsStr(next.text); } - + + // Note: fontWeight, borderRadius, borderWidth, borderColor, padding + // are ignored on iOS due to NMFInfoWindow limitations + // These props work on Android only [super updateProps:props oldProps:oldProps]; } diff --git a/ios/RNCNaverMapViewImpl.mm b/ios/RNCNaverMapViewImpl.mm index 1cde6ba9..f364967d 100644 --- a/ios/RNCNaverMapViewImpl.mm +++ b/ios/RNCNaverMapViewImpl.mm @@ -125,6 +125,7 @@ - (void)insertReactSubview:(id)subview atIndex:(NSInteger)atIndex } 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++) { diff --git a/src/component/NaverMapInfoWindow.tsx b/src/component/NaverMapInfoWindow.tsx index d81f46d7..6042d86d 100644 --- a/src/component/NaverMapInfoWindow.tsx +++ b/src/component/NaverMapInfoWindow.tsx @@ -128,6 +128,10 @@ export interface NaverMapInfoWindowProps * InfoWindow는 마커의 위 또는 지도의 특정 지점에 부가적인 정보를 나타내기 위한 오버레이입니다. * 주로 말풍선 형태로 구성되어 텍스트를 표시하는 용도로 사용합니다. * + * **플랫폼별 스타일 지원:** + * - Android: 모든 스타일 속성 지원 (fontWeight, borderRadius, borderWidth, borderColor, padding) + * - iOS: 기본 텍스트만 지원 (NMFInfoWindow API 제한) + * * @example * ```tsx * // 1. 마커에 연결된 InfoWindow (권장) @@ -140,6 +144,10 @@ export interface NaverMapInfoWindowProps * identifier="marker1" * text="마커 정보" * isOpen={true} + * // Android only: 커스텀 스타일 + * fontWeight="bold" + * borderRadius={10} + * borderColor="#4263eb" * /> * * // 2. 특정 좌표에 InfoWindow 직접 표시 @@ -154,6 +162,7 @@ export interface NaverMapInfoWindowProps * ``` * * @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, From 04fdf45632e100ab51cc4aa7de4f3329886edc89 Mon Sep 17 00:00:00 2001 From: ko-devHong Date: Thu, 16 Oct 2025 11:57:50 +0900 Subject: [PATCH 4/8] feat: add InfoWindowScreen to App --- example/src/App.tsx | 2 + example/src/screens/InfoWindowScreen.tsx | 124 +++++++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 example/src/screens/InfoWindowScreen.tsx 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..e3ec44ac --- /dev/null +++ b/example/src/screens/InfoWindowScreen.tsx @@ -0,0 +1,124 @@ +import { + NaverMapInfoWindow, + NaverMapMarkerOverlay, +} from '@mj-studio/react-native-naver-map'; +import React from 'react'; +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 (Android에선 font bold 사용) */} + + + {/* infoWindow 2: 마커와 연결된 infoWindow (Android에선 borderRadius, backgroundColor 사용) */} + + + {/* infoWindow 3: 마커와 연결된 infoWindow (Android 에선 all custom styles) */} + + + {/* infoWindow 4: 좌표에 독립적으로 표시된 infoWindow */} + + + {/* infoWindow 5: 처음부터 닫힌 infoWindow */} + + + + ); +}; From e87cc0372fd45b4c3e5d63afb2a21689fca26a14 Mon Sep 17 00:00:00 2001 From: ko-devHong Date: Thu, 16 Oct 2025 13:22:43 +0900 Subject: [PATCH 5/8] docs: add NaverMapInfoWindow component for displaying info windows on markers --- .../docs/components/overlays/meta.json | 1 + .../docs/components/overlays/meta.ko.json | 1 + .../overlays/naver-map-info-window.ko.mdx | 61 +++++++++++++++++++ .../overlays/naver-map-info-window.mdx | 61 +++++++++++++++++++ 4 files changed, 124 insertions(+) create mode 100644 docs/content/docs/components/overlays/naver-map-info-window.ko.mdx create mode 100644 docs/content/docs/components/overlays/naver-map-info-window.mdx 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..716549f7 --- /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: 텍스트만 지원(네이티브 API 제약) + +## 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..b8b40bdf --- /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: text-only (limited by native API) + +## 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. +- On Android, you can customize appearance (font, radius, border, background, padding). On iOS, the native API is limited to text. + + From 391b748d568560c32906034cf0aae298a69d6b67 Mon Sep 17 00:00:00 2001 From: ko-devHong Date: Thu, 16 Oct 2025 13:41:58 +0900 Subject: [PATCH 6/8] refactor: clean up whitespace and improve code formatting --- .../mapview/RNCNaverMapView.kt | 8 +- .../mapview/RNCNaverMapViewWrapper.kt | 9 +- .../infowindow/RNCNaverMapInfoWindow.kt | 41 +-- .../RNCNaverMapInfoWindowManager.kt | 6 +- .../overlay/marker/RNCNaverMapMarker.kt | 242 +++++++++--------- .../RNCNaverMapInfoWindowManagerSpec.kt | 4 +- .../InfoWindow/RNCNaverMapInfoWindow.h | 1 - .../InfoWindow/RNCNaverMapInfoWindow.mm | 22 +- ios/Overlay/Marker/RNCNaverMapMarker.mm | 4 +- ios/RNCNaverMapViewImpl.mm | 6 +- 10 files changed, 168 insertions(+), 175 deletions(-) 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 b714d404..55926bf7 100644 --- a/android/src/main/java/com/mjstudio/reactnativenavermap/mapview/RNCNaverMapView.kt +++ b/android/src/main/java/com/mjstudio/reactnativenavermap/mapview/RNCNaverMapView.kt @@ -34,7 +34,7 @@ class RNCNaverMapView( private var attacherGroup: ViewAttacherGroup? = null private var map: NaverMap? = null val overlays = mutableListOf>() - + // Marker registry for InfoWindow lookup val markerRegistry = mutableMapOf() @@ -165,13 +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) @@ -218,7 +218,7 @@ class RNCNaverMapView( 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 index 09723583..cad3860e 100644 --- a/android/src/main/java/com/mjstudio/reactnativenavermap/overlay/infowindow/RNCNaverMapInfoWindow.kt +++ b/android/src/main/java/com/mjstudio/reactnativenavermap/overlay/infowindow/RNCNaverMapInfoWindow.kt @@ -1,7 +1,6 @@ package com.mjstudio.reactnativenavermap.overlay.infowindow import android.annotation.SuppressLint -import android.view.LayoutInflater import android.view.View import android.widget.TextView import com.facebook.react.uimanager.ThemedReactContext @@ -39,19 +38,19 @@ class RNCNaverMapInfoWindow( 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) { @@ -62,7 +61,7 @@ class RNCNaverMapInfoWindow( return } } - + // Fall back to position val pos = position if (pos != null) { @@ -85,7 +84,7 @@ class RNCNaverMapInfoWindow( markerIdentifier = identifier updateInfoWindowState() } - + fun setIsOpen(isOpen: Boolean) { shouldBeOpen = isOpen updateInfoWindowState() @@ -151,48 +150,50 @@ class RNCNaverMapInfoWindow( override fun getView(infoWindow: InfoWindow): View { val paddingPx = this@RNCNaverMapInfoWindowAdapter.padding.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 this.setPadding(paddingPx, paddingPx, paddingPx, paddingPx) - + // Add text view with wrap content - addView(textView, android.widget.FrameLayout.LayoutParams( - android.widget.FrameLayout.LayoutParams.WRAP_CONTENT, - android.widget.FrameLayout.LayoutParams.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 + 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 index 2c597f65..c1585e5a 100644 --- a/android/src/main/java/com/mjstudio/reactnativenavermap/overlay/infowindow/RNCNaverMapInfoWindowManager.kt +++ b/android/src/main/java/com/mjstudio/reactnativenavermap/overlay/infowindow/RNCNaverMapInfoWindowManager.kt @@ -1,11 +1,9 @@ package com.mjstudio.reactnativenavermap.overlay.infowindow -import android.graphics.PointF 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.getAlign import com.mjstudio.reactnativenavermap.util.getLatLng import com.mjstudio.reactnativenavermap.util.getPoint import com.mjstudio.reactnativenavermap.util.isValidNumber @@ -23,7 +21,6 @@ class RNCNaverMapInfoWindowManager : RNCNaverMapInfoWindowManagerSpec Unit) { this?.overlay?.run(fn) } @@ -218,7 +215,7 @@ class RNCNaverMapInfoWindowManager : RNCNaverMapInfoWindowManagerSpec(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/newarch/RNCNaverMapInfoWindowManagerSpec.kt b/android/src/newarch/RNCNaverMapInfoWindowManagerSpec.kt index bf90c592..2370045e 100644 --- a/android/src/newarch/RNCNaverMapInfoWindowManagerSpec.kt +++ b/android/src/newarch/RNCNaverMapInfoWindowManagerSpec.kt @@ -6,7 +6,8 @@ import com.facebook.react.uimanager.ViewManagerDelegate import com.facebook.react.viewmanagers.RNCNaverMapInfoWindowManagerDelegate import com.facebook.react.viewmanagers.RNCNaverMapInfoWindowManagerInterface -abstract class RNCNaverMapInfoWindowManagerSpec : SimpleViewManager(), +abstract class RNCNaverMapInfoWindowManagerSpec : + SimpleViewManager(), RNCNaverMapInfoWindowManagerInterface { private val mDelegate: ViewManagerDelegate @@ -16,4 +17,3 @@ abstract class RNCNaverMapInfoWindowManagerSpec : SimpleViewManager override fun getDelegate(): ViewManagerDelegate = mDelegate } - diff --git a/ios/Overlay/InfoWindow/RNCNaverMapInfoWindow.h b/ios/Overlay/InfoWindow/RNCNaverMapInfoWindow.h index 345b1595..f214d3f3 100644 --- a/ios/Overlay/InfoWindow/RNCNaverMapInfoWindow.h +++ b/ios/Overlay/InfoWindow/RNCNaverMapInfoWindow.h @@ -32,4 +32,3 @@ - (void)updateInfoWindowState; @end - diff --git a/ios/Overlay/InfoWindow/RNCNaverMapInfoWindow.mm b/ios/Overlay/InfoWindow/RNCNaverMapInfoWindow.mm index e123bebe..f301d20e 100644 --- a/ios/Overlay/InfoWindow/RNCNaverMapInfoWindow.mm +++ b/ios/Overlay/InfoWindow/RNCNaverMapInfoWindow.mm @@ -6,8 +6,8 @@ // #import "RNCNaverMapInfoWindow.h" -#import "RNCNaverMapViewImpl.h" #import "RNCNaverMapMarker.h" +#import "RNCNaverMapViewImpl.h" #import using namespace facebook::react; @@ -37,8 +37,8 @@ - (RCTBridge*)bridge { - (instancetype)init { if ((self = [super init])) { _inner = [NMFInfoWindow new]; - _shouldBeOpen = YES; // Default isOpen = true - + _shouldBeOpen = YES; // Default isOpen = true + // Create text data source (iOS only supports text for now) _textDataSource = [NMFInfoWindowDefaultTextSource dataSource]; _textDataSource.title = @""; @@ -72,9 +72,10 @@ - (void)updateInfoWindowState { [_inner close]; return; } - - if (!_currentMapView) return; - + + if (!_currentMapView) + return; + // Try to find marker by identifier first if (_markerIdentifier && _markerIdentifier.length > 0 && _parentMapViewImpl) { RNCNaverMapMarker* markerView = _parentMapViewImpl.markerRegistry[_markerIdentifier]; @@ -84,7 +85,7 @@ - (void)updateInfoWindowState { return; } } - + // Fall back to position _inner.mapView = _currentMapView; } @@ -123,13 +124,13 @@ - (void)updateProps:(Props::Shared const&)props oldProps:(Props::Shared const&)o 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; @@ -141,7 +142,7 @@ - (void)updateProps:(Props::Shared const&)props oldProps:(Props::Shared const&)o if (prev.text != next.text) { _textDataSource.title = getNsStr(next.text); } - + // Note: fontWeight, borderRadius, borderWidth, borderColor, padding // are ignored on iOS due to NMFInfoWindow limitations // These props work on Android only @@ -158,4 +159,3 @@ + (ComponentDescriptorProvider)componentDescriptorProvider { } @end - diff --git a/ios/Overlay/Marker/RNCNaverMapMarker.mm b/ios/Overlay/Marker/RNCNaverMapMarker.mm index 72b04155..71ccb1ba 100644 --- a/ios/Overlay/Marker/RNCNaverMapMarker.mm +++ b/ios/Overlay/Marker/RNCNaverMapMarker.mm @@ -227,10 +227,10 @@ - (void)updateProps:(Props::Shared const&)props oldProps:(Props::Shared const&)o _inner.subCaptionMinZoom = caption.minZoom; _inner.subCaptionMaxZoom = caption.maxZoom; } - + // Set identifier as tag for InfoWindow lookup if (prev.identifier != next.identifier) { - _inner.userInfo = @{@"identifier": getNsStr(next.identifier)}; + _inner.userInfo = @{@"identifier" : getNsStr(next.identifier)}; } [super updateProps:props oldProps:oldProps]; diff --git a/ios/RNCNaverMapViewImpl.mm b/ios/RNCNaverMapViewImpl.mm index f364967d..2fafa5d2 100644 --- a/ios/RNCNaverMapViewImpl.mm +++ b/ios/RNCNaverMapViewImpl.mm @@ -92,7 +92,7 @@ - (void)insertReactSubview:(id)subview atIndex:(NSInteger)atIndex 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) { @@ -141,13 +141,13 @@ - (void)removeReactSubview:(UIView*)subview { if ([subview isKindOfClass:[RNCNaverMapMarker class]]) { 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]]) { From 7cdab6bd84b28adc8400e8ec81a04fb69236f8c2 Mon Sep 17 00:00:00 2001 From: ko-devHong Date: Tue, 21 Oct 2025 17:46:46 +0900 Subject: [PATCH 7/8] feat: implement custom styling support for InfoWindow on iOS --- .../infowindow-implementation.md | 105 ++++++------ .../overlays/naver-map-info-window.ko.mdx | 6 +- .../overlays/naver-map-info-window.mdx | 6 +- example/src/screens/InfoWindowScreen.tsx | 11 +- .../InfoWindow/RNCNaverMapInfoWindow.mm | 160 ++++++++++++++++-- src/component/NaverMapInfoWindow.tsx | 14 +- 6 files changed, 219 insertions(+), 83 deletions(-) diff --git a/.claude/implementations/infowindow-implementation.md b/.claude/implementations/infowindow-implementation.md index 927706e2..0b203242 100644 --- a/.claude/implementations/infowindow-implementation.md +++ b/.claude/implementations/infowindow-implementation.md @@ -29,10 +29,15 @@ InfoWindow는 마커나 특정 좌표에 부가 정보를 표시하는 말풍선 #### RNCNaverMapInfoWindow.kt **위치**: `android/src/main/java/com/mjstudio/reactnativenavermap/overlay/infowindow/` -- `NMFInfoWindow` 래핑 -- 커스텀 어댑터 패턴 구현 (`RNCNaverMapInfoWindowAdapter`) +- `InfoWindow` 래핑 +- **커스텀 어댑터 패턴 구현** (`RNCNaverMapInfoWindowAdapter`) + - `InfoWindow.ViewAdapter()` 상속 + - `getView()` 메서드에서 커스텀 View 생성 + - TextView + FrameLayout으로 구성 + - GradientDrawable로 배경, 테두리, 라운드 코너 구현 - 텍스트 및 스타일 동적 업데이트 -- 마커 연결 지원 (예정) +- 마커 연결 지원 (`identifier`를 통한 Marker Registry 조회) +- 열림/닫힘 상태 제어 #### RNCNaverMapInfoWindowManager.kt **위치**: `android/src/main/java/com/mjstudio/reactnativenavermap/overlay/infowindow/` @@ -54,9 +59,15 @@ InfoWindow는 마커나 특정 좌표에 부가 정보를 표시하는 말풍선 - `NMFInfoWindow` 래핑 - Fabric Component View 구현 - Props 업데이트 처리 -- `dataSource` 블록을 통한 커스텀 뷰 생성 -- UILabel 기반 텍스트 렌더링 -- 스타일링 (테두리, 라운드 코너, 패딩) +- **커스텀 데이터 소스 구현** (`RNCNaverMapInfoWindowDataSource`) + - `NMFOverlayImageDataSource` 프로토콜 채택 + - `viewWithOverlay:` 메서드로 UIView 직접 반환 + - UILabel 기반 텍스트 렌더링 + - CALayer를 통한 스타일링 (테두리, 라운드 코너, 배경색) + - 패딩 및 폰트 굵기 지원 +- 속성 변경 시 `invalidate()` 호출하여 자동 재렌더링 +- Retina 디스플레이 대응 +- `nmap::intToColor()` 함수로 색상 변환 ### 4. React Component @@ -102,7 +113,7 @@ import { NaverMapInfoWindow } from '@mj-studio/react-native-naver-map'; longitude={126.9783881} text="마커 정보" isOpen={true} - // Android only: 커스텀 스타일 + // 커스텀 스타일 (Android & iOS 모두 지원) fontWeight="bold" borderRadius={10} borderColor="#4263eb" @@ -112,7 +123,8 @@ import { NaverMapInfoWindow } from '@mj-studio/react-native-naver-map'; ## 참고 자료 - [Android InfoWindow 공식 문서](https://navermaps.github.io/android-map-sdk/guide-ko/5-3.html) -- [iOS NMFInfoWindow API](https://navermaps.github.io/maps.js.ncp/docs/naver.maps.InfoWindow.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) ## 플랫폼별 스타일 지원 현황 @@ -136,53 +148,36 @@ val drawable = GradientDrawable().apply { } ``` -### iOS ⚠️ (텍스트만 지원) -- ✅ `text` - 텍스트 내용 +### iOS ✅ (완전 지원) +- ✅ `text`, `textSize`, `textColor` +- ✅ `fontWeight` - Regular/Medium/Semibold/Bold (100-900) +- ✅ `backgroundColor` +- ✅ `borderRadius` - 둥근 모서리 +- ✅ `borderWidth`, `borderColor` - 테두리 +- ✅ `padding` - 내부 여백 - ✅ 마커 연결 (`identifier`) - ✅ 열림/닫힘 제어 (`isOpen`) -- ❌ `textSize`, `textColor` - 무시됨 -- ❌ `fontWeight`, `borderRadius`, `borderWidth`, `borderColor`, `padding` - 무시됨 - -**제한 이유:** -iOS의 `NMFInfoWindow`는 기본적으로 `NMFInfoWindowDefaultTextSource`를 사용하며, 이는 말풍선 스타일의 텍스트만 표시합니다. -커스텀 스타일을 위해 `NMFOverlayImageDataSource`를 시도했으나: -- `NMFInfoWindow`가 내부적으로 `toUIImage` 메서드 호출 (존재하지 않음) -- 일반 오버레이와 달리 InfoWindow는 이미지 기반 커스터마이징 미지원 -- 구현 시도 시 에러 발생 내용: `-[NMFOverlayImage toUIImage]: unrecognized selector` - -### iOS에서 커스텀 스타일이 필요한 경우 - -**Option 1: Marker의 Custom View 사용** -```tsx - - - 커스텀 정보 - - +**구현 방식:** +```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; +} ``` -**Option 2: 플랫폼별 조건부 렌더링** -```tsx -{Platform.OS === 'android' ? ( - -) : ( - - - -)} -``` +**주요 특징:** +- Retina 디스플레이 지원 +- 동적 스타일 업데이트 (`invalidate()` 호출) +- 빈 텍스트 처리 및 최소 크기 보장 +- Android와 동일한 모든 스타일 속성 지원 ## 구현 패턴 @@ -206,18 +201,18 @@ iOS의 `NMFInfoWindow`는 기본적으로 `NMFInfoWindowDefaultTextSource`를 - ✅ TypeScript Spec 및 타입 정의 - ✅ Android 네이티브 구현 (모든 스타일 지원) -- ✅ iOS 네이티브 구현 (기본 텍스트) +- ✅ iOS 네이티브 구현 (모든 스타일 지원) - ✅ React Component 작성 - ✅ Package 등록 - ✅ Export 추가 - ✅ 마커 연결 기능 (`identifier`) - ✅ 열림/닫힘 제어 (`isOpen`) - ✅ Marker Registry 구현 -- ⚠️ iOS 커스텀 스타일 (API 제한으로 미지원) +- ✅ iOS 커스텀 스타일 (NMFOverlayImageDataSource 프로토콜 활용) ## 사용 권장사항 -- **간단한 텍스트만 필요**: InfoWindow 사용 (양쪽 플랫폼) -- **커스텀 스타일 필요 (Android만)**: InfoWindow 사용, iOS는 기본 스타일 -- **커스텀 스타일 필요 (양쪽 플랫폼)**: Marker의 Custom View 사용 +- **텍스트 정보 표시**: InfoWindow 사용 (양쪽 플랫폼 모두 완전 지원) +- **커스텀 스타일**: InfoWindow 사용 (Android & iOS 모두 모든 스타일 속성 지원) +- **복잡한 인터랙션**: 필요한 경우 Marker의 Custom View 사용 고려 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 index 716549f7..1d620564 100644 --- a/docs/content/docs/components/overlays/naver-map-info-window.ko.mdx +++ b/docs/content/docs/components/overlays/naver-map-info-window.ko.mdx @@ -10,7 +10,7 @@ NaverMapInfoWindow는 지도 위에 정보 말풍선을 표시합니다. 플랫폼 참고: - Android: 폰트, 배경/테두리/패딩 등 전체 스타일 지원 -- iOS: 텍스트만 지원(네이티브 API 제약) +- iOS: 모든 스타일 속성 지원 (NMFOverlayImageDataSource 프로토콜 활용) ## Basic Usage @@ -27,7 +27,7 @@ function MapWithInfoWindow() { text="서울시청" isOpen align="Top" - // Android 전용 스타일 예시 + // 커스텀 스타일 (Android & iOS 모두 지원) fontWeight="bold" backgroundColor="white" borderRadius={8} @@ -56,6 +56,6 @@ function MapWithInfoWindow() { - `identifier`가 설정되면 동일한 `identifier`를 가진 마커 위에 정보창이 열립니다. - `identifier`가 없으면 `latitude`와 `longitude` 좌표에 표시됩니다. - `align`, `anchor`, `offsetX/offsetY`로 마커 주변 위치를 세밀하게 조정할 수 있습니다. -- Android에서는 모양 스타일을 커스터마이즈할 수 있으며, iOS는 텍스트만 지원합니다. +- 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 index b8b40bdf..9ed54bc5 100644 --- a/docs/content/docs/components/overlays/naver-map-info-window.mdx +++ b/docs/content/docs/components/overlays/naver-map-info-window.mdx @@ -10,7 +10,7 @@ It can be attached to a marker via `identifier` or rendered at an arbitrary coor Platform notes: - Android: supports full styling -- iOS: text-only (limited by native API) +- iOS: supports full styling (using NMFOverlayImageDataSource protocol) ## Basic Usage @@ -27,7 +27,7 @@ function MapWithInfoWindow() { text="Seoul City Hall" isOpen align="Top" - // Android-only styling examples + // Custom styling (supported on both Android & iOS) fontWeight="bold" backgroundColor="white" borderRadius={8} @@ -56,6 +56,6 @@ function MapWithInfoWindow() { - 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. -- On Android, you can customize appearance (font, radius, border, background, padding). On iOS, the native API is limited to text. +- Both Android and iOS support full styling customization (font, radius, border, background, padding). diff --git a/example/src/screens/InfoWindowScreen.tsx b/example/src/screens/InfoWindowScreen.tsx index e3ec44ac..2f82d3e4 100644 --- a/example/src/screens/InfoWindowScreen.tsx +++ b/example/src/screens/InfoWindowScreen.tsx @@ -3,6 +3,7 @@ import { 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'; @@ -49,7 +50,7 @@ export const InfoWindowScreen = ({ onBack }: { onBack: () => void }) => { image={{ symbol: 'yellow' }} /> - {/* infoWindow 1: 마커와 연결된 infoWindow (Android에선 font bold 사용) */} + {/* infoWindow 1: 마커와 연결된 infoWindow (font bold 사용) */} void }) => { textColor="black" fontWeight="bold" backgroundColor="white" - borderRadius={12} - padding={8} + borderRadius={Platform.OS === 'ios' ? 16 : 99} + padding={10} alpha={0.95} /> - {/* infoWindow 2: 마커와 연결된 infoWindow (Android에선 borderRadius, backgroundColor 사용) */} + {/* infoWindow 2: 마커와 연결된 infoWindow (borderRadius, backgroundColor 사용) */} void }) => { isOpen={true} /> - {/* infoWindow 3: 마커와 연결된 infoWindow (Android 에선 all custom styles) */} + {/* infoWindow 3: 마커와 연결된 infoWindow (all custom styles) */} +@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 padding; +@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]; + _padding = 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 + CGFloat totalPadding = _padding * 2; + CGSize containerSize = + CGSizeMake(labelSize.width + totalPadding, labelSize.height + totalPadding); + + // 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(_padding, _padding, 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 @@ -21,7 +117,7 @@ @implementation RNCNaverMapInfoWindow { BOOL _shouldBeOpen; NMFMapView* _currentMapView; RNCNaverMapViewImpl* _parentMapViewImpl; - NMFInfoWindowDefaultTextSource* _textDataSource; + RNCNaverMapInfoWindowDataSource* _customDataSource; } - (RCTBridge*)bridge { @@ -39,10 +135,9 @@ - (instancetype)init { _inner = [NMFInfoWindow new]; _shouldBeOpen = YES; // Default isOpen = true - // Create text data source (iOS only supports text for now) - _textDataSource = [NMFInfoWindowDefaultTextSource dataSource]; - _textDataSource.title = @""; - _inner.dataSource = _textDataSource; + // Create custom data source with styling support + _customDataSource = [[RNCNaverMapInfoWindowDataSource alloc] init]; + _inner.dataSource = _customDataSource; } return self; @@ -137,15 +232,58 @@ - (void)updateProps:(Props::Shared const&)props oldProps:(Props::Shared const&)o [self updateInfoWindowState]; } - // Text content only (iOS custom styling is not supported by NMFInfoWindow API) - // For custom styling on iOS, consider using Marker with custom view instead + // Text content and styling (now supported via custom data source!) + BOOL needsRedraw = NO; + if (prev.text != next.text) { - _textDataSource.title = getNsStr(next.text); + _customDataSource.text = getNsStr(next.text); + needsRedraw = YES; } - // Note: fontWeight, borderRadius, borderWidth, borderColor, padding - // are ignored on iOS due to NMFInfoWindow limitations - // These props work on Android only + 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.infoWindowPadding != next.infoWindowPadding) { + _customDataSource.padding = next.infoWindowPadding; + needsRedraw = YES; + } + + // Redraw the info window if any styling property changed + if (needsRedraw) { + [_inner invalidate]; + } [super updateProps:props oldProps:oldProps]; } diff --git a/src/component/NaverMapInfoWindow.tsx b/src/component/NaverMapInfoWindow.tsx index 6042d86d..a52cd536 100644 --- a/src/component/NaverMapInfoWindow.tsx +++ b/src/component/NaverMapInfoWindow.tsx @@ -129,8 +129,8 @@ export interface NaverMapInfoWindowProps * 주로 말풍선 형태로 구성되어 텍스트를 표시하는 용도로 사용합니다. * * **플랫폼별 스타일 지원:** - * - Android: 모든 스타일 속성 지원 (fontWeight, borderRadius, borderWidth, borderColor, padding) - * - iOS: 기본 텍스트만 지원 (NMFInfoWindow API 제한) + * - Android: 모든 스타일 속성 지원 (ViewAdapter를 통한 커스텀 뷰 렌더링) + * - iOS: 모든 스타일 속성 지원 (NMFOverlayImageDataSource를 통한 커스텀 이미지 렌더링) * * @example * ```tsx @@ -144,10 +144,11 @@ export interface NaverMapInfoWindowProps * identifier="marker1" * text="마커 정보" * isOpen={true} - * // Android only: 커스텀 스타일 + * // 커스텀 스타일 (Android & iOS 모두 지원) * fontWeight="bold" * borderRadius={10} * borderColor="#4263eb" + * padding={12} * /> * * // 2. 특정 좌표에 InfoWindow 직접 표시 @@ -155,9 +156,10 @@ export interface NaverMapInfoWindowProps * latitude={37.5666102} * longitude={126.9783881} * text="서울시청" - * textSize={14} - * textColor="black" - * backgroundColor="white" + * textSize={16} + * textColor="white" + * backgroundColor="#4263eb" + * borderRadius={8} * /> * ``` * From 12f5507718bedd8237266c1212d72d40e25aca28 Mon Sep 17 00:00:00 2001 From: ko-devHong Date: Tue, 21 Oct 2025 17:58:17 +0900 Subject: [PATCH 8/8] feat: enhance InfoWindow component with separate horizontal and vertical padding properties --- .../infowindow/RNCNaverMapInfoWindow.kt | 19 ++++++++++----- .../RNCNaverMapInfoWindowManager.kt | 14 ++++++++--- example/src/screens/InfoWindowScreen.tsx | 9 ++++--- .../InfoWindow/RNCNaverMapInfoWindow.mm | 24 ++++++++++++------- src/component/NaverMapInfoWindow.tsx | 19 +++++++++++---- .../RNCNaverMapInfoWindowNativeComponent.ts | 9 +++++-- 6 files changed, 67 insertions(+), 27 deletions(-) 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 index cad3860e..dae0865a 100644 --- a/android/src/main/java/com/mjstudio/reactnativenavermap/overlay/infowindow/RNCNaverMapInfoWindow.kt +++ b/android/src/main/java/com/mjstudio/reactnativenavermap/overlay/infowindow/RNCNaverMapInfoWindow.kt @@ -130,8 +130,13 @@ class RNCNaverMapInfoWindow( customAdapter?.let { overlay.adapter = it as InfoWindow.Adapter } } - fun setInfoWindowPadding(padding: Float) { - customAdapter?.padding = padding + 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 } } @@ -146,10 +151,12 @@ class RNCNaverMapInfoWindow( var borderRadius: Float = 5f var borderWidth: Float = 1f var borderColor: Int = android.graphics.Color.parseColor("#cccccc") - var padding: Float = 10f + var paddingHorizontal: Float = 10f + var paddingVertical: Float = 10f override fun getView(infoWindow: InfoWindow): View { - val paddingPx = this@RNCNaverMapInfoWindowAdapter.padding.toInt() + val paddingHorizontalPx = this@RNCNaverMapInfoWindowAdapter.paddingHorizontal.toInt() + val paddingVerticalPx = this@RNCNaverMapInfoWindowAdapter.paddingVertical.toInt() val textView = TextView(context).apply { this.text = this@RNCNaverMapInfoWindowAdapter.text ?: "" @@ -169,8 +176,8 @@ class RNCNaverMapInfoWindow( // Container with border, background and padding val container = android.widget.FrameLayout(context).apply { - // Add padding to container - this.setPadding(paddingPx, paddingPx, paddingPx, paddingPx) + // Add padding to container (horizontal and vertical separately) + this.setPadding(paddingHorizontalPx, paddingVerticalPx, paddingHorizontalPx, paddingVerticalPx) // Add text view with wrap content addView( 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 index c1585e5a..36c12947 100644 --- a/android/src/main/java/com/mjstudio/reactnativenavermap/overlay/infowindow/RNCNaverMapInfoWindowManager.kt +++ b/android/src/main/java/com/mjstudio/reactnativenavermap/overlay/infowindow/RNCNaverMapInfoWindowManager.kt @@ -200,12 +200,20 @@ class RNCNaverMapInfoWindowManager : RNCNaverMapInfoWindowManagerSpec void }) => { fontWeight="bold" backgroundColor="white" borderRadius={Platform.OS === 'ios' ? 16 : 99} - padding={10} + paddingHorizontal={10} + paddingVertical={8} alpha={0.95} /> @@ -76,7 +77,8 @@ export const InfoWindowScreen = ({ onBack }: { onBack: () => void }) => { fontWeight="700" backgroundColor="#ff6b6b" borderRadius={14} - padding={10} + paddingHorizontal={12} + paddingVertical={10} alpha={1} isOpen={true} /> @@ -94,7 +96,8 @@ export const InfoWindowScreen = ({ onBack }: { onBack: () => void }) => { borderRadius={8} borderWidth={1} borderColor="#f39c12" - padding={6} + paddingHorizontal={8} + paddingVertical={6} alpha={0.9} /> diff --git a/ios/Overlay/InfoWindow/RNCNaverMapInfoWindow.mm b/ios/Overlay/InfoWindow/RNCNaverMapInfoWindow.mm index 18763819..7a839c2b 100644 --- a/ios/Overlay/InfoWindow/RNCNaverMapInfoWindow.mm +++ b/ios/Overlay/InfoWindow/RNCNaverMapInfoWindow.mm @@ -22,7 +22,8 @@ @interface RNCNaverMapInfoWindowDataSource : NSObject * * // 2. 특정 좌표에 InfoWindow 직접 표시 @@ -193,7 +200,8 @@ export const NaverMapInfoWindow = ({ borderRadius = 5, borderWidth = 1, borderColor = '#ccc', - padding = 10, + paddingHorizontal = 10, + paddingVertical = 10, children, }: NaverMapInfoWindowProps) => { @@ -227,7 +235,8 @@ export const NaverMapInfoWindow = ({ infoWindowBorderRadius={borderRadius} infoWindowBorderWidth={borderWidth} infoWindowBorderColor={processColor(borderColor) as number} - infoWindowPadding={padding} + infoWindowPaddingHorizontal={paddingHorizontal} + infoWindowPaddingVertical={paddingVertical} > {children} diff --git a/src/spec/RNCNaverMapInfoWindowNativeComponent.ts b/src/spec/RNCNaverMapInfoWindowNativeComponent.ts index d2a2ddf2..bb610cb7 100644 --- a/src/spec/RNCNaverMapInfoWindowNativeComponent.ts +++ b/src/spec/RNCNaverMapInfoWindowNativeComponent.ts @@ -106,10 +106,15 @@ interface Props extends BaseOverlay, ViewProps { */ infoWindowBorderColor?: Int32; /** - * 내부 여백 (픽셀) + * 수평 내부 여백 (픽셀) * @default 10 */ - infoWindowPadding?: WithDefault; + infoWindowPaddingHorizontal?: WithDefault; + /** + * 수직 내부 여백 (픽셀) + * @default 10 + */ + infoWindowPaddingVertical?: WithDefault; } export default codegenNativeComponent('RNCNaverMapInfoWindow');