TextEditor, 이것도 안된다고? (3/3) - 커스텀 구현 과정

Taeyoung Won·2023년 4월 15일
4

SwiftUI

목록 보기
5/7
post-thumbnail

이전 글에 이어서 커스텀 TextEditor 구현 과정을 알아보자.

TextEditor Custom View 구현

이제 View를 채택한 Custom View로 TextEditor를 커스텀한 구조체를 만들어보자.




Property

struct CustomTextEditorView: View {
        
	// MARK: -Properties
	// MARK: Stored Properties
	let const = Constant.TextEditorConst.self

	let style: GSTextEditorStyle
	let text: Binding<String>
	let font: Font
	let lineSpace: CGFloat

	// MARK: Initializer에서 계산을 통해 결정되는 프로퍼티
	let maxLineCount: CGFloat
	let uiFont: UIFont
	let maxHeight: CGFloat
    
    @State private var currentTextEditorHeight: CGFloat = 0
    @State private var maxTextWidth: CGFloat = 0
	...
        
}

const는 TextEditor를 구현하는데 필요한 리터럴 값을 관리하는 열거형이다.

우리 프로젝트는 디자인 시스템을 사용하기 때문에, Style 열거형을 통해 컴포넌트의 디자인을 결정한다. 디자인 시스템 도입기도 다음에 글로 풀어보고 싶다.

그 외에 text, font, lineSpace 등 TextEditor에 전달할 파라미터 혹은 속성 값을 초기화 시점에 전달받도록 했다.

maxLineCount, uiFont, maxHeight는 생성 시점에 1회만 계산이 필요한 프로퍼티여서 연산 프로퍼티로 분리하지 않고 이니셜라이저 내부에서 계산하여 할당하도록 했다.

currentTextEditorHeight는 TextEditor의 frame height에 전달되어 실제로 View에 그려지는 TextEditor의 높이를 실시간으로 결정하고 업데이트한다.

maxTextWidth는 TextEditor의 width를 통해 계산된 text의 최대 가로 길이인데 왜 @State로 선언되었는지와 어디에 활용되는지는 밑에서 다시 알아보자.

// MARK: -Initializer
/// 파라미터 font = .body, lineSpace = 2 기본값 지정
init (
	style: GSTextEditorStyle,
	text: Binding<String>,
	font: Font = .body,
	lineSpace: CGFloat = 2,
) {
	self.style = style
	self.text = text
	self.font = font
	self.lineSpace = lineSpace

	self.maxLineCount = const.TEXTEDITOR_MAX_LINE_COUNT.asFloat
	self.uiFont = UIFont.fontToUIFont(from: font)
	self.maxHeight = (maxLineCount * (uiFont.lineHeight + lineSpace)) + const.TEXTEDITOR_FRAME_HEIGHT_FREESPACE
}




줄바꿈?

이쯤에서 이번 커스텀의 가장 핵심인 줄바꿈에 대해 짚고 넘어가보자.

사용자가 키패드의 return 버튼을 눌러서 \n(개행문자)를 추가하게 되면 TextEditor 내부에서는 줄바꿈이 일어나서 다음 라인의 leading 위치부터 이어서 입력이 된다.

TextEditor의 높이를 계산하기 위해서는 현재 text가 몇 줄인지를 알아야하고, 이 개행문자의 갯수를 알 수 있다면 text의 라인 갯수도 알 수 있는 것이다.

그래서 개행문자를 통해 현재 text의 줄 갯수를 계산해주는 연산 프로퍼티를 구현했다.

// MARK: Computed Properties
// 현재 text에 개행문자에 의한 줄 갯수가 몇 줄인지 계산하는 프로퍼티
private var newLineCount: CGFloat {
    let currentText: String = text.wrappedValue
    let currentLineCount: Int = currentText.filter{$0 == "\n"}.count + 1
    return currentLineCount > maxLineCount.asInt
    ? maxLineCount
    : currentLineCount.asFloat
}

제작 배경 설명에서 언급한 것처럼 5줄 이상부터는 높이가 고정되기 때문에, 5줄을 초과하면 5줄로 고정하도록 했다.

반환 타입을 CGFloat로 설정한 이유는 이 값을 height 등 다른 CGFloat 값과 연산해야 하기 때문이다.

이대로 구현을 하면서 문제를 마주치게 되는데...




자동 줄바꿈?

사실 개행문자 말고도 줄바꿈이 되는 경우가 하나 더 있다.

줄바꿈은 사용자가 return 버튼을 눌렀을 때 수동으로 추가되기도 하지만, 텍스트가 너무 길어졌을 때 (입력 텍스트의 width가 TextEditor의 width보다 커질 때)도 자동으로 일어난다.

사실 처음에는 이 부분을 고려하지 못하고 개행문자로만 높이를 계산했다.

팀원 중에 한 분이 테스트 과정에서 말씀해주신 "이거 텍스트가 길어졌을 때는 높이가 안늘어나네요?"라는 한 마디에 깨닫게 되었다.

심지어 이 케이스는 개행문자처럼 text 내부에 \n라는 문자가 포함되는 것이 아니라 View를 그리는 과정에서 처리되는 것이기 때문에 위의 newLineCount 프로퍼티에서 줄바꿈을 캐치할 수 없었다.




자동 줄바꿈을 감지해보자

고민하다가 떠올린 방법은 text의 width가 TextEditor의 width를 넘어가면 줄바꿈으로 감지하는 방법이었다.

이를 계산하기 위한 연산 프로퍼티를 구현했다.

// 개행 문자 기준으로 텍스트를 분리하고, 각 텍스트 길이가 Editor 길이를 초과하는지 체크하여 필요한 줄바꿈 수를 계산하는 프로퍼티
private var autoLineCount: CGFloat {
	var counter: Int = 0
	text.wrappedValue.components(separatedBy: "\n").forEach { line in
    	let label = UILabel()
    	label.font = .fontToUIFont(from: font)
    	label.text = line
    	label.sizeToFit()
    	let currentTextWidth = label.frame.width
    	counter += Int(currentTextWidth / maxTextWidth)
	}
	return counter.asFloat
}

현재의 font 기준으로 text를 담은 UILabel을 만들고 frame을 통해 width 값을 구했다.

text를 개행문자 기준으로 분리해서 계산한 이유는 text를 분리하지 않고 width를 구하면 첫 번째 줄의 width만 인식하기 때문이다.

그래서 개행문자 기준으로 분리한 각 라인의 width를 계산해서 maxTextWidth로 나눈 정수값을 counter에 더해주었다.

예를 들어 TextEditor의 가로 길이가 300이고, text를 개행문자 기준으로 분리해서 총 3줄의 문자열이 나왔다고 가정하자.

각 문자열 가로 길이
920, 170, 390

각 문자열이 TextEditor 가로 길이를 초과한 횟수
Int(920/300) = 3, Int(170/300) = 0, Int(390/300) = 1

counter에 더하는 값
3+0+1 = 4

이렇게 총 4줄을 줄바꿈으로 추가해주는 것이다.




TextEditor 구현

이제 준비물이 모두 준비되었으니 body를 구현해보자.

GeometryReader { proxy in
    TextEditor(text: text)
        .modifier(
            GSTextEditorLayoutModifier(
                font: font,
                color: .primary,
                lineSpace: lineSpace,
                maxHeight: currentTextEditorHeight,
                horizontalInset: const.TEXTEDITOR_INSET_HORIZONTAL,
                bottomInset: const.TEXTEDITOR_INSET_BOTTOM
            )
        )
        .overlay {
            RoundedRectangle(cornerRadius: const.TEXTEDITOR_STROKE_CORNER_RADIUS)
                .stroke()
                .foregroundColor(.gsGray2)
        }
        .onAppear {
            setTextEditorStartHeight()
            setMaxTextWidth(proxy: proxy)
        }
        .onChange(of: text.wrappedValue) { n in
            updateTextEditorCurrentHeight()
        }
}
.frame(maxHeight: currentTextEditorHeight)

Modifier와 overlay 스코프는 TextEditor의 디자인과 관련된 부분이니까 생략하자.

onAppear 내부를 보면 처음 View가 그려질 때 TextEditor의 시작 높이와 text의 최대 가로 길이를 계산해서 할당한다.


// MARK: -Methods
// textEditor 시작 높이를 세팅해주는 메서드
private func setTextEditorStartHeight() {
    currentTextEditorHeight = uiFont.lineHeight + const.TEXTEDITOR_FRAME_HEIGHT_FREESPACE
}

// text가 가질 수 있는 최대 길이를 세팅해주는 메서드
private func setMaxTextWidth(proxy: GeometryProxy) {
    maxTextWidth = proxy.size.width - (const.TEXTEDITOR_INSET_HORIZONTAL * 2 + 10)
}

실제로 TextEditor는 캡슐 모양처럼 코너가 둥글게 처리되어 있고 text가 입력되는 영역 사이에 inset이 적용되어있기 때문에, 최대 text 길이를 계산할 때도 버퍼를 주었다.

가장 핵심 로직은 onChange 스코프 내부에 있는 updateTextEditorCurrentHeight() 함수다.


// MARK: line count를 통해 textEditor 현재 높이를 계산해서 업데이트하는 메서드
private func updateTextEditorCurrentHeight() {
    // 총 라인 갯수
    let totalLineCount = newLineCount + autoLineCount
    
    // 총 라인 갯수가 maxCount 이상이면 최대 높이로 고정
    guard totalLineCount < maxLineCount else {
        currentTextEditorHeight = maxHeight
        return
    }
    
    // 라인 갯수로 계산한 현재 Editor 높이
    let currentHeight = (totalLineCount * (uiFont.lineHeight + lineSpace))
    + const.TEXTEDITOR_FRAME_HEIGHT_FREESPACE
    
    // View의 높이를 결정하는 State 변수에 계산된 현재 높이를 할당하여 뷰에 반영
    currentTextEditorHeight = currentHeight
}

이전에 구현한 연산 프로퍼티를 활용해서 현재 TextEditor 내에 적용해야하는 총 라인 갯수를 구한다.

이미 라인 갯수가 최대 갯수를 넘었으면 이니셜라이저에서 미리 계산해 둔 maxHeight@State currentTextEditorHeight에 전달해서 최대 높이로 고정한다.

아직 최대 라인 갯수를 넘지 않았다면 현재 라인 갯수, 폰트 높이, 행간, 상하 여유공간을 통해 View에 그려줄 TextEditor의 높이를 계산해서 전달한다.

onChange 내부에서 text의 입력이 변할 때마다 해당 로직을 통해서 실시간으로 text를 분석하므로써 높이를 동적으로 변화시켜주는 TextEditor를 구현했다.




후기

가볍게 View를 커스텀하는 일은 자주 있었지만, 이렇게 디테일하게 구현해본 적은 처음이었던 것 같다.

실제로 구현할 때는 시행착오도 많았지만, 완성하고보니 이번 프로젝트에서 맡았던 채팅 기능만큼이나 애착이 가는 결과물이 나왔다.

실제로 현업에서도 기획/디자인팀의 요청이나 이번 케이스처럼 최소 지원 버전의 문제로 사용할 수 없는 View를 직접 구현하는 일이 많을 것이라고 예상되는 만큼 좋은 경험이었다고 생각한다.

profile
iOS Developer

1개의 댓글

comment-user-thumbnail
2024년 1월 29일

원태님 구글신이 여기로 이끌었습니다

답글 달기