현재 테블릿보다 사양이 훨씬 낮은 테블릿에서 자연스럽게 동작해야하는 허들이 있다.
따라서 이번에 Fabric View 를 직접 만들어서 사용해보기로했다.
글자가 특정 길이 이상일때, 흐르는 marquee 애니메이션이 들어가야한다.
일단 기존의 TurboModule 과 같은 폴더구조로 생성해주었다.
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)
}
}
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 정리
}
}
이렇게 구성해주었다.
New architecture 에서 큰 변화였던 Fabric UI 를 직접 작성해서 써본데 큰 의의를 두었다.