Skip to main content

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

info

네이티브 모듈(Native Module)과 네이티브 컴포넌트(Native Components)는 기존 아키텍처에서 사용하던 안정적인 기술이다. 새로운 아키텍처가 안정화되면 이 기술들은 점차 사용되지 않을 예정이다. 새로운 아키텍처는 터보 네이티브 모듈패브릭 네이티브 컴포넌트를 사용해 유사한 결과를 달성한다.

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

네이티브 모듈 가이드와 마찬가지로, 이 가이드도 Android SDK 프로그래밍에 어느 정도 익숙하다는 전제하에 작성된 고급 가이드이다. 이 가이드는 네이티브 UI 컴포넌트를 구축하는 방법을 보여주며, React Native 코어 라이브러리에 포함된 ImageView 컴포넌트의 일부를 구현하는 과정을 단계별로 설명한다.

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에서 반환하는 이름은 자바스크립트에서 네이티브 뷰 타입을 참조할 때 사용한다.

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으로 선언하고 반환 값이 없어야 한다(즉, Java에서는 void, Kotlin에서는 Unit을 반환 타입으로 사용). JS로 전송되는 프로퍼티 타입은 설정자 메서드의 값 인자 타입에 따라 자동으로 결정된다. 현재 지원되는 값 타입은 다음과 같다(Java 기준): boolean, int, float, double, String, Boolean, Integer, ReadableArray, ReadableMap. Kotlin에서는 Boolean, Int, Float, Double, String, ReadableArray, ReadableMap에 해당한다.

@ReactProp 어노테이션은 필수 인자로 name을 받는다. 이 name은 설정자 메서드와 연결되며, JS 측에서 프로퍼티를 참조할 때 사용된다.

name 외에도 @ReactProp 어노테이션은 다음과 같은 선택적 인자를 받을 수 있다: defaultBoolean, defaultInt, defaultFloat. 이 인자들은 해당 타입(Java에서는 boolean, int, float, Kotlin에서는 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에서 자유롭게 제어할 수 있는 네이티브 뷰 컴포넌트를 노출하는 방법을 알게 되었다. 그렇다면 사용자의 핀치 줌이나 패닝 같은 이벤트는 어떻게 처리할까? 네이티브 이벤트가 발생하면 네이티브 코드는 뷰의 JavaScript 표현에 이벤트를 전달해야 한다. 두 뷰는 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);
}
}

JavaScript에서 topChange 이벤트 이름을 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();
}
}

이 콜백은 원시 이벤트와 함께 호출되며, 일반적으로 래퍼 컴포넌트에서 처리해 더 간단한 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. Fragment 생성하기

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 예제를 참고한다.