티스토리 뷰
커스텀 위젯을 만드는 이유
UI를 구성하기 전에 커스텀 위젯을 만드는 것은 반필수적이라고 볼 수 있다.
정의한 커스텀 위젯은 재사용이 가능하기에 UI의 일관성을 유지할 수 있다.
또한 screen을 구성하는 코드가 확연하게 줄어들어 가독성이 향상된다.
커스텀 위젯 디자인 방식
컴포저블 함수를 선언한 뒤 그 안에 기본위젯을 넣고 그 위젯의
프로퍼티를 활용해 피그마 디자인과 동일하게 UI를 구성한 다음
컴포저블 함수의 프로퍼티로 위젯의 디자인이나 동작을 제어하도록 한다.
(버튼 활성화/비활성화 상태라던지, 텍스트필드 입력 모드라던지 등등..)
커스텀 위젯 네이밍
커스텀 위젯의 이름은 컴포즈에서 기본적으로 제공하는 위젯과
동일한 이름으로 해도 되지만 사용 시 커스텀 위젯이 아닌 기본 위젯이 import되는
번거로운 문제가 생길 수 있어 네이밍을 custom + 위젯이름 이런식으로 하는 걸 추천한다.
필자는 이름이 너무 길어지는게 싫어 커스텀 위젯을 기본 위젯과 동일한 이름으로 설정했다.
Text
@Composable
fun Text(
text: String,
modifier: Modifier = Modifier,
style: TextStyle = CustomTheme.typography.b4Regular,
color: Color = CustomTheme.colors.text
) {
Text(
text = text,
modifier = modifier,
style = style,
color = color
)
}
테마 파일에서 기본적인 텍스트 스타일을 지정할 수 있긴 하지만
미리보기에는 적용이 안되기 때문에 따로 기본 텍스트 위젯을 만들어준다.
Svg
@Composable
fun Svg(
drawableId: Int,
size: Dp = 24.dp,
startPadding: Dp = 0.dp,
onClick: () -> Unit = {},
isIcon: Boolean = false,
iconColor: Color = CustomTheme.colors.icon
) {
Image(
imageVector = ImageVector.vectorResource(drawableId),
contentDescription = null,
modifier = Modifier
.size(size)
.padding(start = startPadding)
.clickable(onClick = onClick),
colorFilter = if (isIcon) ColorFilter.tint(iconColor) else null
)
}
컴포즈에서 벡터 이미지를 사용하려면 이미지 위젯을 활용해야 한다.
Material Icon과 그냥 벡터 이미지 둘 다 사용할 수 있도록 위젯을 구성하였다.
Divider
@Composable
fun Divider(width: Dp, color: Color) {
HorizontalDivider(
color = color,
thickness = 1.dp,
modifier = Modifier.width(width)
)
}
구분선을 간단하게 사용할 수 있도록 해준다.
Button UI
OutlinedButton과 Buttton 이렇게 총 2개의 커스텀 위젯을 만들 것이다.
Button
@Composable
fun Button(
text: String,
onClick: () -> Unit,
color: Color,
enabled: Boolean = true,
) {
Button(
onClick = onClick,
enabled = enabled,
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(
disabledContainerColor = CustomTheme.colors.disabled,
containerColor = color
)
) {
Text(
style = CustomTheme.typography.b3Regular,
text = text,
color = CustomTheme.colors.buttonText
)
}
}
커스텀 버튼은 버튼 텍스트, 클릭 이벤트, 배경색상, 활성화 여부를
프로퍼티로 제어할 수 있도록 한다.
OutlinedButton
@Composable
fun OutlinedButton(
onClick: () -> Unit,
text: String,
color: Color,
enabled: Boolean = true
) {
val buttonColor = if (enabled) color else CustomTheme.colors.disabled.copy().copy(alpha = 0.5f)
OutlinedButton(
onClick = onClick,
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
contentPadding = PaddingValues(0.dp),
border = BorderStroke(width = 1.dp, color = buttonColor),
shape = RoundedCornerShape(4.dp),
colors = ButtonDefaults.buttonColors(
containerColor = CustomTheme.colors.background,
disabledContainerColor = CustomTheme.colors.background.copy(alpha = 0.5f)
),
enabled = enabled,
) {
Text(
text = text,
style = CustomTheme.typography.b4Regular.copy(color = buttonColor)
)
}
}
OutliendButton은 Button과 동일하나 비활성화 시 모든 요소의 색이 변하도록 설정한다.
TextField UI
텍스트 필드도 버튼과 마찬가지로 OutlinedTextField와 TextField
이렇게 총 2개의 커스텀 위젯을 만들것이다.
텍스트필드는 비밀번호 입력모드도 추가해야한다.
TextField
@Composable
fun TextField(
value: String,
onValueChange: (String) -> Unit,
drawableId: Int,
hint: String,
isPassword: Boolean = false,
isLast: Boolean = false,
maxLength: Int = 50
) {
val focusRequester = remember { FocusRequester() }
var isPasswordHidden by remember { mutableStateOf(true) }
val visualTransformation =
if (isPassword && isPasswordHidden) PasswordVisualTransformation() else VisualTransformation.None
val visibleIcon =
if (isPasswordHidden) R.drawable.ic_visibility_off else R.drawable.ic_visibility
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Svg(drawableId = drawableId, startPadding = 4.dp, isIcon = true)
BasicTextField(
value = value,
textStyle = CustomTheme.typography.b4Regular.copy(
color = CustomTheme.colors.text
),
onValueChange = {
if (it.length <= maxLength && it.all { c -> !c.isWhitespace() }) {
onValueChange(it)
}
},
cursorBrush = SolidColor(CustomTheme.colors.text),
maxLines = 1,
modifier = Modifier
.weight(1f)
.height(48.dp)
.focusRequester(focusRequester),
decorationBox = { innerTextField ->
Box(
modifier = Modifier
.weight(1f)
.padding(start = 8.dp),
contentAlignment = Alignment.CenterStart
) {
if (value.isEmpty()) {
Text(
text = hint,
style = CustomTheme.typography.b4Regular.copy(
color = CustomTheme.colors.hint
),
)
}
innerTextField()
}
},
visualTransformation = visualTransformation,
keyboardOptions = KeyboardOptions.Default.copy(
imeAction = if (isLast) ImeAction.Done else ImeAction.Next
),
)
if (isPassword) {
Svg(
drawableId = visibleIcon,
onClick = { isPasswordHidden = !isPasswordHidden },
size = 20.dp,
isIcon = true
)
}
Spacer(modifier = Modifier.width(12.dp))
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(1.dp)
.background(color = CustomTheme.colors.border)
.padding(start = 8.dp)
)
}
기본 텍스트필드로는 원하는 UI를 구성하는 것이 불가했기에
BasicTextField를 활용하여 커스텀 텍스트필드 위젯을 만들었다.
비밀번호 입력모드일 때의 텍스트 필드는 입력한 값이 가려진 상태이며
우측 아이콘을 클릭하여 텍스트 가림 여부를 제어할 수 있다.
OutlinedTextField
@Composable
fun OutlinedTextField(
value: String,
onValueChange: (String) -> Unit,
hint: String,
isPassword: Boolean = false,
isLastField: Boolean = false,
maxLength: Int = 50,
enabled: Boolean = true
) {
var isPasswordHidden by remember { mutableStateOf(true) }
val focusRequester = remember { FocusRequester() }
val textColor =
if (enabled) CustomTheme.colors.text else CustomTheme.colors.disabled.copy(alpha = 0.5f)
val borderColor =
if (enabled) CustomTheme.colors.border else CustomTheme.colors.disabled.copy(alpha = 0.5f)
val visualTransformation =
if (isPassword && isPasswordHidden) PasswordVisualTransformation() else VisualTransformation.None
val visibleIcon =
if (isPasswordHidden) R.drawable.ic_visibility_off else R.drawable.ic_visibility
Box(
modifier = Modifier
.border(width = 1.dp, color = borderColor, shape = RoundedCornerShape(4.dp))
.background(CustomTheme.colors.background)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
BasicTextField(
enabled = enabled,
value = value,
textStyle = CustomTheme.typography.b4Regular.copy(color = textColor),
onValueChange = {
if (it.length <= maxLength && it.all { c -> !c.isWhitespace() }) {
onValueChange(it)
}
},
maxLines = 1,
cursorBrush = SolidColor(CustomTheme.colors.text),
modifier = Modifier
.weight(1f)
.height(48.dp)
.focusRequester(focusRequester),
decorationBox = { innerTextField ->
Box(
modifier = Modifier
.weight(1f)
.padding(start = 12.dp),
contentAlignment = Alignment.CenterStart
) {
if (value.isEmpty()) {
Text(
text = hint,
style = CustomTheme.typography.b4Regular.copy(
color = CustomTheme.colors.hint
),
)
}
innerTextField()
}
},
visualTransformation = visualTransformation,
keyboardOptions = KeyboardOptions.Default.copy(
imeAction = if (isLastField) ImeAction.Done else ImeAction.Next
),
)
if (isPassword) {
Svg(
drawableId = visibleIcon,
onClick = { isPasswordHidden = !isPasswordHidden },
size = 20.dp,
isIcon = true
)
}
Spacer(modifier = Modifier.width(12.dp))
}
}
}
기존 커스텀 텍스트필드 위젯에서 테두리를 추가하고, 우측 아이콘 영역을 제거하였다.
CheckBox UI
체크박스는 활성화 시 띄울 체크 모양 아이콘이 하나 필요하다.
CheckBox
@Composable
fun Checkbox(
isChecked: Boolean,
onClick: (Boolean) -> Unit,
modifier: Modifier = Modifier
) {
val checkBoxColor = if (isChecked) CustomTheme.colors.skyBlue else CustomTheme.colors.background
Box(
modifier = modifier
.size(18.dp)
.clickable { onClick(!isChecked) }
.background(
color = checkBoxColor,
shape = RoundedCornerShape(4.dp)
)
.then(
if (!isChecked) {
Modifier.border(1.dp, CustomTheme.colors.hint, RoundedCornerShape(4.dp))
} else {
Modifier
}
)
) {
if (isChecked) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_check),
tint = CustomTheme.colors.buttonText,
contentDescription = null,
modifier = Modifier.align(Alignment.Center)
)
}
}
}
체크 여부에 따라 다른 레이아웃을 보여주도록 한다.
'멸치탈출 > 개발일지' 카테고리의 다른 글
Compose 로그인 화면 구현 (구글, 카카오, 네이버 소셜 로그인) (0) | 2024.11.11 |
---|---|
프로젝트 기본 설정 (2) | 2024.11.07 |
안드로이드 개인 프로젝트 구상 (기술적 의사 결정) (6) | 2024.10.29 |