728x90
반응형

현재 react native 0.79 버전으로 앱을 리뉴얼하고있는데,

 

현재 테블릿보다 사양이 훨씬 낮은 테블릿에서 자연스럽게 동작해야하는 허들이 있다.

 

따라서 이번에 Fabric View 를 직접 만들어서 사용해보기로했다.

 

사용할곳은, 상품을 보여주는 컴포넌트이다. 

 

해당 컴포넌트가 장바구니 열림 / 닫힘에 의해 애니메이션이 되어야하고,

 

글자가 특정 길이 이상일때, 흐르는 marquee 애니메이션이 들어가야한다.

 

일단 기존의 TurboModule 과 같은 폴더구조로 생성해주었다.

 

 

ProductItemView.kt

package 프로젝트이름.productitem

import android.animation.ObjectAnimator
import android.content.Context
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.view.View
import android.view.ViewGroup
import android.view.animation.AccelerateDecelerateInterpolator
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.cardview.widget.CardView
import com.facebook.react.bridge.ReadableMap
import com.facebook.react.fabric.FabricUIManager
import com.facebook.react.fabric.mounting.MountingManager
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.uimanager.ViewGroupManager
import com.facebook.react.uimanager.annotations.ReactProp
import com.facebook.react.views.view.ReactViewGroup
import com.facebook.drawee.view.SimpleDraweeView
import com.facebook.drawee.backends.pipeline.Fresco
import com.facebook.drawee.generic.GenericDraweeHierarchy
import com.facebook.drawee.generic.GenericDraweeHierarchyBuilder
import com.facebook.drawee.drawable.ScalingUtils

class ProductItemView(context: Context) : ViewGroup(context) {
private var containerLayout: LinearLayout? = null
private var imageView: SimpleDraweeView? = null
private var titleText: TextView? = null
private var priceText: TextView? = null
private var isDrawerOpen: Boolean = false
private var baseWidth: Int = 0
private var drawerWidth: Int = 0
private var currentWidth: Int = 0
private var widthAnimator: ObjectAnimator? = null
private var imageHeight: Int = 200 // 기본 이미지 높이
private var totalHeight: Int = 0 // 전체 높이
private var currentImageUrl: String? = null // 현재 이미지 URL 추적
 
init {
setupViews()
// 디버깅을 위한 배경색 설정
setBackgroundColor(Color.YELLOW)
}
 
private fun setupViews() {
println("ProductItemView: setupViews started")
 
// LinearLayout 컨테이너 설정
containerLayout = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
layoutParams = ViewGroup.MarginLayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
setMargins(8, 4, 8, 4)
}
// 디버깅을 위한 배경색
setBackgroundColor(Color.RED)
}
 
// 이미지 뷰 설정 개선
val hierarchy = GenericDraweeHierarchyBuilder(context.resources)
.setActualImageScaleType(ScalingUtils.ScaleType.CENTER_CROP)
.setPlaceholderImage(ColorDrawable(Color.LTGRAY))
.setFailureImage(ColorDrawable(Color.RED))
.build()
 
imageView = SimpleDraweeView(context).apply {
this.hierarchy = hierarchy
layoutParams = ViewGroup.MarginLayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
imageHeight
).apply {
setMargins(0, 0, 0, 8)
}
// 디버깅을 위한 배경색
setBackgroundColor(Color.CYAN)
}
 
// 제목 TextView
titleText = TextView(context).apply {
textSize = 16f
setTextColor(Color.BLACK)
layoutParams = ViewGroup.MarginLayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
setMargins(16, 8, 16, 4)
}
// 디버깅을 위한 배경색
setBackgroundColor(Color.GREEN)
// 테스트 텍스트 추가
text = "Test Title"
}
 
// 가격 TextView
priceText = TextView(context).apply {
textSize = 14f
setTextColor(Color.GRAY)
layoutParams = ViewGroup.MarginLayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
setMargins(16, 4, 16, 12)
}
// 디버깅을 위한 배경색
setBackgroundColor(Color.MAGENTA)
// 테스트 텍스트 추가
text = "Test Price"
}
 
// LinearLayout에 뷰들 추가
containerLayout?.addView(imageView)
containerLayout?.addView(titleText)
containerLayout?.addView(priceText)
 
println("ProductItemView: Added ${containerLayout?.childCount} children to containerLayout")
 
// 메인 컨테이너에 LinearLayout 추가
addView(containerLayout)
 
println("ProductItemView: Added containerLayout to main view, total children: $childCount")
println("ProductItemView: setupViews completed")
}
 
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
println("ProductItemView: onLayout called - changed: $changed, l: $l, t: $t, r: $r, b: $b")
 
val child = getChildAt(0)
if (child != null) {
println("ProductItemView: Laying out child with width: ${r - l}, height: ${b - t}")
child.layout(0, 0, r - l, b - t)
 
// 자식 뷰의 실제 크기 확인
println("ProductItemView: Child actual width: ${child.width}, height: ${child.height}")
println("ProductItemView: Child measured width: ${child.measuredWidth}, height: ${child.measuredHeight}")
} else {
println("ProductItemView: No child found!")
}
}
 
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
println("ProductItemView: onMeasure called - widthSpec: $widthMeasureSpec, heightSpec: $heightMeasureSpec")
 
val child = getChildAt(0)
if (child != null) {
// 자식 뷰 측정
measureChild(child, widthMeasureSpec, heightMeasureSpec)
 
// 자식 뷰의 측정된 크기 사용
val childWidth = child.measuredWidth
val childHeight = child.measuredHeight
 
println("ProductItemView: Child measured - width: $childWidth, height: $childHeight")
 
// 부모 뷰의 크기를 자식 뷰에 맞춤
setMeasuredDimension(childWidth, childHeight)
} else {
println("ProductItemView: No child to measure, using default size")
setMeasuredDimension(
MeasureSpec.getSize(widthMeasureSpec),
MeasureSpec.getSize(heightMeasureSpec)
)
}
}
 
fun setProductData(title: String, price: String, imageUrl: String?) {
println("ProductItemView: setProductData - title: $title, price: $price, imageUrl: $imageUrl")
 
titleText?.text = title
priceText?.text = price
 
// 이미지 설정 개선
imageUrl?.let { url ->
if (url.isNotEmpty()) {
println("ProductItemView: Setting image URL: $url")
currentImageUrl = url
imageView?.setImageURI(url)
} else {
println("ProductItemView: Empty image URL, setting null")
currentImageUrl = null
// 기본 이미지 설정
imageView?.setImageURI(null as String?)
}
} ?: run {
println("ProductItemView: Null image URL, setting null")
currentImageUrl = null
// null인 경우 기본 이미지 설정
imageView?.setImageURI(null as String?)
}
}
 
fun setImageHeight(height: Int) {
println("ProductItemView: setImageHeight - $height")
imageHeight = height
imageView?.layoutParams?.height = height
imageView?.requestLayout()
}
 
fun setTotalHeight(height: Int) {
println("ProductItemView: setTotalHeight - $height")
totalHeight = height
val layoutParams = this.layoutParams ?: ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
layoutParams.height = height
this.layoutParams = layoutParams
requestLayout()
}
 
fun setDrawerState(open: Boolean) {
isDrawerOpen = open
animateWidth()
}
 
fun setWidths(base: Int, drawer: Int) {
baseWidth = base
drawerWidth = drawer
if (currentWidth == 0) {
currentWidth = if (isDrawerOpen) baseWidth - drawerWidth else baseWidth
updateWidthImmediate()
} else {
animateWidth()
}
}
 
private fun animateWidth() {
val targetWidth = if (isDrawerOpen) baseWidth - drawerWidth else baseWidth
 
// 기존 애니메이션 취소
widthAnimator?.cancel()
 
// 성능 최적화: ObjectAnimator 사용
widthAnimator = ObjectAnimator.ofInt(this, "width", currentWidth, targetWidth).apply {
duration = 250 // 더 빠른 애니메이션
interpolator = AccelerateDecelerateInterpolator()
 
addUpdateListener { animator ->
currentWidth = animator.animatedValue as Int
// 레이아웃 업데이트 최적화
requestLayout()
}
 
start()
}
}
 
private fun updateWidthImmediate() {
val layoutParams = layoutParams ?: ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
layoutParams.width = currentWidth
this.layoutParams = layoutParams
 
// 즉시 레이아웃 업데이트
requestLayout()
}
 
// ObjectAnimator를 위한 setter
fun setWidth(width: Int) {
currentWidth = width
updateWidthImmediate()
}
 
// Getter 메서드들 추가
fun getTitleText(): TextView? = titleText
fun getPriceText(): TextView? = priceText
fun getBaseWidth(): Int = baseWidth
fun getDrawerWidth(): Int = drawerWidth
fun getCurrentImageUrl(): String? = currentImageUrl
}

class ProductItemViewManager : ViewGroupManager<ProductItemView>() {
override fun getName(): String = "ProductItemView"
 
override fun createViewInstance(reactContext: ThemedReactContext): ProductItemView {
println("ProductItemViewManager: createViewInstance called")
return ProductItemView(reactContext)
}
 
@ReactProp(name = "title")
fun setTitle(view: ProductItemView, title: String) {
println("ProductItemViewManager: setTitle called with: $title")
val currentPrice = view.getPriceText()?.text?.toString() ?: ""
val currentImageUrl = view.getCurrentImageUrl() ?: ""
view.setProductData(title, currentPrice, currentImageUrl)
}
 
@ReactProp(name = "price")
fun setPrice(view: ProductItemView, price: String) {
println("ProductItemViewManager: setPrice called with: $price")
val currentTitle = view.getTitleText()?.text?.toString() ?: ""
val currentImageUrl = view.getCurrentImageUrl() ?: ""
view.setProductData(currentTitle, price, currentImageUrl)
}
 
@ReactProp(name = "imageUrl")
fun setImageUrl(view: ProductItemView, imageUrl: String) {
println("ProductItemViewManager: setImageUrl called with: $imageUrl")
val currentTitle = view.getTitleText()?.text?.toString() ?: ""
val currentPrice = view.getPriceText()?.text?.toString() ?: ""
view.setProductData(currentTitle, currentPrice, imageUrl)
}
 
@ReactProp(name = "imageHeight")
fun setImageHeight(view: ProductItemView, height: Int) {
println("ProductItemViewManager: setImageHeight called with: $height")
view.setImageHeight(height)
}
 
@ReactProp(name = "totalHeight")
fun setTotalHeight(view: ProductItemView, height: Int) {
println("ProductItemViewManager: setTotalHeight called with: $height")
view.setTotalHeight(height)
}
 
@ReactProp(name = "isDrawerOpen")
fun setIsDrawerOpen(view: ProductItemView, isOpen: Boolean) {
println("ProductItemViewManager: setIsDrawerOpen called with: $isOpen")
view.setDrawerState(isOpen)
}
 
@ReactProp(name = "baseWidth")
fun setBaseWidth(view: ProductItemView, width: Int) {
println("ProductItemViewManager: setBaseWidth called with: $width")
view.setWidths(width, view.getDrawerWidth())
}
 
@ReactProp(name = "drawerWidth")
fun setDrawerWidth(view: ProductItemView, width: Int) {
println("ProductItemViewManager: setDrawerWidth called with: $width")
view.setWidths(view.getBaseWidth(), width)
}
}

 

ProductItemLayoutModulePackage.kt

package 프로젝트.productitem

import com.facebook.react.TurboReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.module.model.ReactModuleInfo
import com.facebook.react.module.model.ReactModuleInfoProvider
import java.util.HashMap

class ProductItemLayoutModulePackage : TurboReactPackage() {
override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? {
return if (name == ProductItemLayoutModule.NAME) {
ProductItemLayoutModule(reactContext)
} else {
null
}
}

override fun getReactModuleInfoProvider(): ReactModuleInfoProvider {
return ReactModuleInfoProvider {
val moduleInfos: MutableMap<String, ReactModuleInfo> = HashMap()
moduleInfos[ProductItemLayoutModule.NAME] = ReactModuleInfo(
ProductItemLayoutModule.NAME,
ProductItemLayoutModule.NAME,
false, // canOverrideExistingModule
false, // needsEagerInit
false, // isCXXModule
true // isTurboModule
)
moduleInfos
}
}
}

 

ProductItemLayoutModule.kt

package 프로젝트.productitem

import com.facebook.react.bridge.*
import com.facebook.react.module.annotations.ReactModule
import com.facebook.react.turbomodule.core.interfaces.CallInvokerHolder
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReadableArray
import com.facebook.react.bridge.WritableArray
import com.facebook.react.bridge.WritableMap
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Callback
import com.facebook.react.turbomodule.core.interfaces.TurboModule

@ReactModule(name = ProductItemLayoutModule.NAME)
class ProductItemLayoutModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext), TurboModule {

companion object {
const val NAME = "ProductItemLayoutModule"
}

override fun getName(): String = NAME

@ReactMethod
fun calculateItemWidth(
screenWidth: Double,
drawerWidth: Double,
isDrawerOpen: Boolean,
numColumns: Int,
itemSpacing: Double,
promise: Promise
) {
try {
val containerWidth = if (isDrawerOpen) screenWidth - drawerWidth else screenWidth
val totalSpacing = itemSpacing * (numColumns + 2)
val availableWidth = containerWidth - totalSpacing
val itemWidth = availableWidth / numColumns
 
promise.resolve(itemWidth)
} catch (e: Exception) {
promise.reject("CALCULATION_ERROR", e.message, e)
}
}

@ReactMethod
fun calculateItemHeight(
itemWidth: Double,
textHeight: Double,
promise: Promise
) {
try {
val imageHeight = itemWidth * 0.7
val margin = 0.0
val itemHeight = imageHeight + margin * 2 + textHeight
 
promise.resolve(itemHeight)
} catch (e: Exception) {
promise.reject("CALCULATION_ERROR", e.message, e)
}
}

@ReactMethod
fun calculateBatchLayouts(
screenWidth: Double,
drawerWidth: Double,
isDrawerOpen: Boolean,
numColumns: Int,
itemSpacing: Double,
textHeight: Double,
itemCount: Int,
promise: Promise
) {
try {
val containerWidth = if (isDrawerOpen) screenWidth - drawerWidth else screenWidth
val totalSpacing = itemSpacing * (numColumns + 2)
val availableWidth = containerWidth - totalSpacing
val itemWidth = availableWidth / numColumns
val imageHeight = itemWidth * 0.7
val itemHeight = imageHeight + textHeight
val margin = itemSpacing / 2

val result = Arguments.createMap().apply {
putDouble("itemWidth", itemWidth)
putDouble("itemHeight", itemHeight)
putDouble("imageHeight", imageHeight)
putDouble("margin", margin)
}
 
promise.resolve(result)
} catch (e: Exception) {
promise.reject("CALCULATION_ERROR", e.message, e)
}
}

@ReactMethod
fun getOptimizedLayout(
screenWidth: Double,
drawerWidth: Double,
isDrawerOpen: Boolean,
numColumns: Int,
itemSpacing: Double,
textHeight: Double,
callback: Callback
) {
try {
val containerWidth = if (isDrawerOpen) screenWidth - drawerWidth else screenWidth
val totalSpacing = itemSpacing * (numColumns + 2)
val availableWidth = containerWidth - totalSpacing
val itemWidth = availableWidth / numColumns
val imageHeight = itemWidth * 0.7
val itemHeight = imageHeight + textHeight
val margin = itemSpacing / 2

val result = Arguments.createMap().apply {
putDouble("itemWidth", itemWidth)
putDouble("itemHeight", itemHeight)
putDouble("imageHeight", imageHeight)
putDouble("margin", margin)
putBoolean("isDrawerOpen", isDrawerOpen)
}
 
callback.invoke(null, result)
} catch (e: Exception) {
callback.invoke(e.message, null)
}
}

// JSI 동기 메서드 (TurboModule용)
@ReactMethod(isBlockingSynchronousMethod = true)
fun calculateLayoutSync(
screenWidth: Double,
drawerWidth: Double,
isDrawerOpen: Boolean,
numColumns: Int,
itemSpacing: Double,
textHeight: Double
): WritableMap {
val containerWidth = if (isDrawerOpen) screenWidth - drawerWidth else screenWidth
val totalSpacing = itemSpacing * (numColumns + 2)
val availableWidth = containerWidth - totalSpacing
val itemWidth = availableWidth / numColumns
val imageHeight = itemWidth * 0.7
val itemHeight = imageHeight + textHeight
val margin = itemSpacing / 2

return Arguments.createMap().apply {
putDouble("itemWidth", itemWidth)
putDouble("itemHeight", itemHeight)
putDouble("imageHeight", imageHeight)
putDouble("margin", margin)
}
}

override fun invalidate() {
// TurboModule 정리
}
}

 

ProductItemFabricPackage.kt

package 프로젝트.productitem

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

class ProductItemFabricPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
return emptyList()
}

override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
return listOf(ProductItemViewManager())
}
}

 

 

이렇게 구성을 해주고

 

specs/NativeProductItemLayoutModule.ts

import type {TurboModule} from 'react-native';
import {TurboModuleRegistry} from 'react-native';

export interface Spec extends TurboModule {
calculateItemWidth(
screenWidth: number,
drawerWidth: number,
isDrawerOpen: boolean,
numColumns: number,
itemSpacing: number,
): Promise<number>;

calculateItemHeight(
itemWidth: number,
textHeight: number,
): Promise<number>;

calculateBatchLayouts(
screenWidth: number,
drawerWidth: number,
isDrawerOpen: boolean,
numColumns: number,
itemSpacing: number,
textHeight: number,
itemCount: number,
): Promise<{
itemWidth: number;
itemHeight: number;
imageHeight: number;
margin: number;
}>;

getOptimizedLayout(
screenWidth: number,
drawerWidth: number,
isDrawerOpen: boolean,
numColumns: number,
itemSpacing: number,
textHeight: number,
): Promise<{
itemWidth: number;
itemHeight: number;
imageHeight: number;
margin: number;
isDrawerOpen: boolean;
}>;

// JSI 최적화된 동기 메서드
calculateLayoutSync(
screenWidth: number,
drawerWidth: number,
isDrawerOpen: boolean,
numColumns: number,
itemSpacing: number,
textHeight: number,
): {
itemWidth: number;
itemHeight: number;
imageHeight: number;
margin: number;
};
}

export default TurboModuleRegistry.getEnforcing<Spec>('ProductItemLayoutModule');

 

이렇게 구성해주었다.

 

( package.json 이

 

"codegenConfig": {
"name": "MyTurboModules",
"type": "modules",
"jsSrcsDir": "specs",
"android": {
"javaPackageName": "프로젝트"
}
},

 

기때문에, specs/ 에 넣어놓았고, TurboModule 을 적용할때, ts 파일의 처음 이 Native 로 시작해야했다.)

 

codegen 으로

 

 

NativeProductItemLayoutModuleSpec.java

이 생긴걸 확인했다.

 

체감상 react native reanimated 도 fabric 을 이용하고있기에, 엄청 성능이 더 뛰어난것같은 느낌은 받지못했지만

 

New architecture 에서 큰 변화였던 Fabric UI 를 직접 작성해서 써본데 큰 의의를 두었다.

 

(회사 코드가 아니라 작성을 위해 다시 작성한 코드입니다)

728x90
반응형

+ Recent posts