Skip to main content
Version: Next

안드로이드 네이티브 UI 컴포넌트

info

Native Module과 Native Components는 기존 아키텍처에서 사용하던 안정적인 기술이다.
새로운 아키텍처가 안정화되면 앞으로 지원이 중단될 예정이다. 새로운 아키텍처에서는 Turbo Native ModuleFabric Native Components를 사용해 유사한 결과를 달성한다.

최신 앱에서 사용할 수 있는 다양한 네이티브 UI 위젯이 존재한다. 일부는 플랫폼에 내장되어 있고, 다른 것들은 서드파티 라이브러리로 제공되며, 또 다른 것들은 여러분의 포트폴리오에서 사용 중일 수도 있다. React Native는 ScrollViewTextInput 같은 가장 중요한 플랫폼 컴포넌트를 이미 래핑해 제공하지만, 모든 컴포넌트를 포함하지는 않으며, 특히 이전 앱에서 직접 작성한 컴포넌트는 포함되지 않는다. 다행히도, 기존 컴포넌트를 래핑해 React Native 애플리케이션과 원활하게 통합할 수 있다.

네이티브 모듈 가이드와 마찬가지로, 이 가이드는 Android SDK 프로그래밍에 어느 정도 익숙한 개발자를 대상으로 한 고급 가이드이다. 이 가이드는 React Native 코어 라이브러리에 있는 ImageView 컴포넌트의 일부를 구현하는 과정을 통해 네이티브 UI 컴포넌트를 만드는 방법을 설명한다.

info

한 줄의 명령어로 네이티브 컴포넌트를 포함한 로컬 라이브러리를 설정할 수도 있다. 자세한 내용은 로컬 라이브러리 설정 가이드를 참고한다.

ImageView 예제

이 예제에서는 JavaScript에서 ImageView를 사용할 수 있도록 구현 요구 사항을 단계별로 살펴본다.

네이티브 뷰는 ViewManager를 확장하거나, 더 일반적으로 SimpleViewManager를 확장하여 생성하고 조작한다. SimpleViewManager는 배경색, 투명도, 플렉스 박스 레이아웃과 같은 공통 속성을 적용하기 때문에 이 경우에 편리하다.

이러한 서브클래스는 기본적으로 싱글톤이다. 브리지에 의해 각각의 인스턴스가 하나만 생성된다. 이들은 네이티브 뷰를 NativeViewHierarchyManager로 보내고, 필요한 경우 뷰의 속성을 설정하고 업데이트하기 위해 다시 위임한다. ViewManagers는 일반적으로 뷰의 대리자 역할도 하며, 이벤트를 브리지를 통해 JavaScript로 전송한다.

뷰를 전송하려면 다음 단계를 따른다:

  1. ViewManager 서브클래스를 생성한다.
  2. createViewInstance 메서드를 구현한다.
  3. @ReactProp(또는 @ReactPropGroup) 어노테이션을 사용해 뷰 속성 설정자를 노출한다.
  4. 애플리케이션 패키지의 createViewManagers에서 매니저를 등록한다.
  5. JavaScript 모듈을 구현한다.

1. ViewManager 서브클래스 생성하기

이 예제에서는 ReactImageView 타입의 SimpleViewManager를 확장한 ReactImageManager 뷰 매니저 클래스를 만든다. ReactImageView는 매니저가 관리하는 객체 타입으로, 커스텀 네이티브 뷰가 된다. getName이 반환하는 이름은 JavaScript에서 네이티브 뷰 타입을 참조할 때 사용된다.

java
public class ReactImageManager extends SimpleViewManager<ReactImageView> {

public static final String REACT_CLASS = "RCTImageView";
ReactApplicationContext mCallerContext;

public ReactImageManager(ReactApplicationContext reactContext) {
mCallerContext = reactContext;
}

@Override
public String getName() {
return REACT_CLASS;
}
}

2. createViewInstance 메서드 구현

뷰는 createViewInstance 메서드에서 생성된다. 뷰는 기본 상태로 초기화해야 하며, 모든 속성은 이후 updateView 호출을 통해 설정된다.

java
  @Override
public ReactImageView createViewInstance(ThemedReactContext context) {
return new ReactImageView(context, Fresco.newDraweeControllerBuilder(), null, mCallerContext);
}

3. @ReactProp (또는 @ReactPropGroup) 어노테이션을 사용해 뷰 속성 설정자 노출하기

자바스크립트에서 반영할 속성은 @ReactProp (또는 @ReactPropGroup) 어노테이션이 달린 설정자 메서드로 노출해야 한다. 설정자 메서드는 첫 번째 인자로 업데이트할 뷰(현재 뷰 타입)를, 두 번째 인자로 속성 값을 받는다. 설정자는 public으로 선언하고, 반환 값이 없어야 한다(즉, 자바에서는 void, 코틀린에서는 Unit). JS로 전송되는 속성 타입은 설정자의 두 번째 인자 타입에 따라 자동으로 결정된다. 현재 지원하는 값의 타입은 다음과 같다(자바 기준): boolean, int, float, double, String, Boolean, Integer, ReadableArray, ReadableMap. 코틀린에서는 Boolean, Int, Float, Double, String, ReadableArray, ReadableMap에 해당한다.

@ReactProp 어노테이션은 필수 인자인 name을 포함한다. 이 name은 설정자 메서드와 연결되며, JS 측에서 속성을 참조할 때 사용된다.

name 외에도 @ReactProp 어노테이션은 다음과 같은 선택적 인자를 가질 수 있다: defaultBoolean, defaultInt, defaultFloat. 이 인자들은 해당 타입(자바에서는 boolean, int, float, 코틀린에서는 Boolean, Int, Float)이어야 하며, 설정자가 참조하는 속성이 컴포넌트에서 제거된 경우 이 값이 설정자 메서드에 전달된다. 기본값은 원시 타입에만 제공되며, 복잡한 타입의 설정자인 경우 속성이 제거되면 null이 기본값으로 전달된다.

@ReactPropGroup 어노테이션을 사용한 설정자 메서드의 선언 요구사항은 @ReactProp와 다르다. 자세한 내용은 @ReactPropGroup 어노테이션 클래스 문서를 참고한다. 중요! ReactJS에서 속성 값을 업데이트하면 설정자 메서드가 호출된다. 컴포넌트를 업데이트하는 방법 중 하나는 이전에 설정된 속성을 제거하는 것이다. 이 경우에도 설정자 메서드가 호출되어 뷰 매니저에게 속성이 변경되었음을 알린다. 이때 원시 타입의 경우 defaultBoolean, defaultFloat 등의 @ReactProp 어노테이션 인자로 기본값을 지정할 수 있으며, 복잡한 타입의 경우 null이 전달된다.

java
  @ReactProp(name = "src")
public void setSrc(ReactImageView view, @Nullable ReadableArray sources) {
view.setSource(sources);
}

@ReactProp(name = "borderRadius", defaultFloat = 0f)
public void setBorderRadius(ReactImageView view, float borderRadius) {
view.setBorderRadius(borderRadius);
}

@ReactProp(name = ViewProps.RESIZE_MODE)
public void setResizeMode(ReactImageView view, @Nullable String resizeMode) {
view.setScaleType(ImageResizeMode.toScaleType(resizeMode));
}

4. ViewManager 등록하기

마지막 단계는 ViewManager를 애플리케이션에 등록하는 것이다. 이 과정은 네이티브 모듈과 유사하게 애플리케이션 패키지의 멤버 함수인 createViewManagers를 통해 이루어진다.

java
  @Override
public List<ViewManager> createViewManagers(
ReactApplicationContext reactContext) {
return Arrays.<ViewManager>asList(
new ReactImageManager(reactContext)
);
}

5. JavaScript 모듈 구현

마지막 단계는 새로운 뷰를 사용하는 사용자들을 위해 Java/Kotlin과 JavaScript 간의 인터페이스 계층을 정의하는 JavaScript 모듈을 만드는 것이다. 이 모듈에서 컴포넌트 인터페이스를 문서화하는 것을 권장한다(예: TypeScript, Flow, 또는 일반 주석 사용).

ImageView.tsx
import {requireNativeComponent} from 'react-native';

/**
* `View`를 합성한다.
*
* - src: Array<{url: string}>
* - borderRadius: number
* - resizeMode: 'cover' | 'contain' | 'stretch'
*/
export default requireNativeComponent('RCTImageView');

requireNativeComponent 함수는 네이티브 뷰의 이름을 인자로 받는다. 컴포넌트가 더 복잡한 작업을 수행해야 하는 경우(예: 커스텀 이벤트 핸들링), 네이티브 컴포넌트를 다른 React 컴포넌트로 감싸야 한다. 아래 MyCustomView 예제에서 이를 확인할 수 있다.

이벤트 처리

이제 JS에서 자유롭게 제어할 수 있는 네이티브 뷰 컴포넌트를 노출하는 방법을 알았다. 그렇다면 사용자의 핀치 줌이나 패닝과 같은 이벤트는 어떻게 처리할까? 네이티브 이벤트가 발생하면 네이티브 코드는 뷰의 JS 표현에 이벤트를 전달해야 한다. 이때 두 뷰는 getId() 메서드가 반환한 값으로 연결된다.

java
class MyCustomView extends View {
...
public void onReceiveNativeEvent() {
WritableMap event = Arguments.createMap();
event.putString("message", "MyMessage");
ReactContext reactContext = (ReactContext)getContext();
reactContext
.getJSModule(RCTEventEmitter.class)
.receiveEvent(getId(), "topChange", event);
}
}

topChange 이벤트 이름을 JS의 onChange 콜백 prop에 매핑하려면, ViewManager에서 getExportedCustomBubblingEventTypeConstants 메서드를 오버라이드하여 등록한다.

java
public class ReactImageManager extends SimpleViewManager<MyCustomView> {
...
public Map getExportedCustomBubblingEventTypeConstants() {
return MapBuilder.builder().put(
"topChange",
MapBuilder.of(
"phasedRegistrationNames",
MapBuilder.of("bubbled", "onChange")
)
).build();
}
}

이 콜백은 raw 이벤트와 함께 호출된다. 일반적으로 이 이벤트는 래퍼 컴포넌트에서 처리하여 더 간단한 API를 만든다.

MyCustomView.tsx
import {useCallback} from 'react';
import {requireNativeComponent} from 'react-native';

const RCTMyCustomView = requireNativeComponent('RCTMyCustomView');

export default function MyCustomView(props: {
// ...
/**
* 사용자가 지도를 드래그할 때 연속적으로 호출되는 콜백.
*/
onChangeMessage: (message: string) => unknown;
}) {
const onChange = useCallback(
event => {
props.onChangeMessage?.(event.nativeEvent.message);
},
[props.onChangeMessage],
);

return <RCTMyCustomView {...props} onChange={props.onChange} />;
}

안드로이드 프래그먼트와의 통합 예제

기존 네이티브 UI 엘리먼트를 React Native 앱에 통합하려면, ViewManager에서 View를 반환하는 것보다 더 세밀한 제어가 필요할 수 있다. 이때 안드로이드 프래그먼트를 사용하면 된다. 특히 onViewCreated, onPause, onResume 같은 라이프사이클 메서드를 활용해 뷰와 연결된 커스텀 로직을 추가하고자 할 때 유용하다. 아래 단계를 따라 구체적인 방법을 알아보자.

1. 커스텀 뷰 예제 만들기

먼저 FrameLayout을 상속받는 CustomView 클래스를 만든다. 이 뷰의 내용은 여러분이 렌더링하고 싶은 어떤 뷰든 가능하다.

CustomView.java
// 패키지를 여러분의 것으로 변경
package com.mypackage;

import android.content.Context;
import android.graphics.Color;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.TextView;

import androidx.annotation.NonNull;

public class CustomView extends FrameLayout {
public CustomView(@NonNull Context context) {
super(context);
// 패딩과 배경색 설정
this.setPadding(16,16,16,16);
this.setBackgroundColor(Color.parseColor("#5FD3F3"));

// 기본 텍스트 뷰 추가
TextView text = new TextView(context);
text.setText("Welcome to Android Fragments with React Native.");
this.addView(text);
}
}

2. 프래그먼트 생성하기

MyFragment.java
// 패키지를 자신의 것으로 교체
package com.mypackage;

import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.fragment.app.Fragment;

// 뷰의 임포트를 자신의 것으로 교체
import com.mypackage.CustomView;

public class MyFragment extends Fragment {
CustomView customView;

@Override
public View onCreateView(LayoutInflater inflater, ViewGroup parent, Bundle savedInstanceState) {
super.onCreateView(inflater, parent, savedInstanceState);
customView = new CustomView(this.getContext());
return customView; // 이 CustomView는 렌더링하려는 어떤 뷰든 가능
}

@Override
public void onViewCreated(View view, Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
// `onCreate` 메서드에서 처리해야 할 로직을 여기에 추가
// 예: customView.onCreate(savedInstanceState);
}

@Override
public void onPause() {
super.onPause();
// `onPause` 메서드에서 처리해야 할 로직을 여기에 추가
// 예: customView.onPause();
}

@Override
public void onResume() {
super.onResume();
// `onResume` 메서드에서 처리해야 할 로직을 여기에 추가
// 예: customView.onResume();
}

@Override
public void onDestroy() {
super.onDestroy();
// `onDestroy` 메서드에서 처리해야 할 로직을 여기에 추가
// 예: customView.onDestroy();
}
}

3. ViewManager 서브클래스 생성

MyViewManager.java
// 패키지를 자신의 것으로 변경
package com.mypackage;

import android.view.Choreographer;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.FragmentActivity;

import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReadableArray;
import com.facebook.react.common.MapBuilder;
import com.facebook.react.uimanager.annotations.ReactProp;
import com.facebook.react.uimanager.annotations.ReactPropGroup;
import com.facebook.react.uimanager.ViewGroupManager;
import com.facebook.react.uimanager.ThemedReactContext;

import java.util.Map;

public class MyViewManager extends ViewGroupManager<FrameLayout> {

public static final String REACT_CLASS = "MyViewManager";
public final int COMMAND_CREATE = 1;
private int propWidth;
private int propHeight;

ReactApplicationContext reactContext;

public MyViewManager(ReactApplicationContext reactContext) {
this.reactContext = reactContext;
}

@Override
public String getName() {
return REACT_CLASS;
}

/**
* 프래그먼트를 담을 FrameLayout 반환
*/
@Override
public FrameLayout createViewInstance(ThemedReactContext reactContext) {
return new FrameLayout(reactContext);
}

/**
* "create" 커맨드를 정수로 매핑
*/
@Nullable
@Override
public Map<String, Integer> getCommandsMap() {
return MapBuilder.of("create", COMMAND_CREATE);
}

/**
* "create" 커맨드 처리 (JS에서 호출됨) 및 createFragment 메서드 호출
*/
@Override
public void receiveCommand(
@NonNull FrameLayout root,
String commandId,
@Nullable ReadableArray args
) {
super.receiveCommand(root, commandId, args);
int reactNativeViewId = args.getInt(0);
int commandIdInt = Integer.parseInt(commandId);

switch (commandIdInt) {
case COMMAND_CREATE:
createFragment(root, reactNativeViewId);
break;
default: {}
}
}

@ReactPropGroup(names = {"width", "height"}, customType = "Style")
public void setStyle(FrameLayout view, int index, Integer value) {
if (index == 0) {
propWidth = value;
}

if (index == 1) {
propHeight = value;
}
}

/**
* React Native 뷰를 커스텀 프래그먼트로 교체
*/
public void createFragment(FrameLayout root, int reactNativeViewId) {
ViewGroup parentView = (ViewGroup) root.findViewById(reactNativeViewId);
setupLayout(parentView);

final MyFragment myFragment = new MyFragment();
FragmentActivity activity = (FragmentActivity) reactContext.getCurrentActivity();
activity.getSupportFragmentManager()
.beginTransaction()
.replace(reactNativeViewId, myFragment, String.valueOf(reactNativeViewId))
.commit();
}

public void setupLayout(View view) {
Choreographer.getInstance().postFrameCallback(new Choreographer.FrameCallback() {
@Override
public void doFrame(long frameTimeNanos) {
manuallyLayoutChildren(view);
view.getViewTreeObserver().dispatchOnGlobalLayout();
Choreographer.getInstance().postFrameCallback(this);
}
});
}

/**
* 모든 자식 뷰를 적절히 배치
*/
public void manuallyLayoutChildren(View view) {
// propWidth와 propHeight는 react-native props에서 전달됨
int width = propWidth;
int height = propHeight;

view.measure(
View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY));

view.layout(0, 0, width, height);
}
}

4. ViewManager 등록하기

MyPackage.java
// 패키지를 여러분의 것으로 변경하세요
package com.mypackage;

import com.facebook.react.ReactPackage;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.uimanager.ViewManager;

import java.util.Arrays;
import java.util.List;

public class MyPackage implements ReactPackage {

@Override
public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) {
return Arrays.<ViewManager>asList(
new MyViewManager(reactContext)
);
}

}

5. Package 등록하기

MainApplication.java
@Override
protected List<ReactPackage> getPackages() {
List<ReactPackage> packages = new PackageList(this).getPackages();
// 자동 연결이 되지 않는 패키지는 여기에 수동으로 추가할 수 있다. 예를 들어:
// packages.add(new MyReactNativePackage());
packages.add(new MyAppPackage());
return packages;
}

6. 자바스크립트 모듈 구현

I. 커스텀 뷰 매니저부터 시작:

MyViewManager.tsx
import {requireNativeComponent} from 'react-native';

export const MyViewManager =
requireNativeComponent('MyViewManager');

II. create 메서드를 호출하는 커스텀 뷰 구현:

MyView.tsx
import React, {useEffect, useRef} from 'react';
import {
PixelRatio,
UIManager,
findNodeHandle,
} from 'react-native';

import {MyViewManager} from './my-view-manager';

const createFragment = viewId =>
UIManager.dispatchViewManagerCommand(
viewId,
// 'create' 커맨드를 호출
UIManager.MyViewManager.Commands.create.toString(),
[viewId],
);

export const MyView = () => {
const ref = useRef(null);

useEffect(() => {
const viewId = findNodeHandle(ref.current);
createFragment(viewId);
}, []);

return (
<MyViewManager
style={{
// dpi를 px로 변환, 원하는 높이 지정
height: PixelRatio.getPixelSizeForLayoutSize(200),
// dpi를 px로 변환, 원하는 너비 지정
width: PixelRatio.getPixelSizeForLayoutSize(200),
}}
ref={ref}
/>
);
};

@ReactProp (또는 @ReactPropGroup) 어노테이션을 사용해 프로퍼티 설정자를 노출하려면 위의 ImageView 예제를 참고한다.