SwiftUI로 Placeholder가 존재하는 TextField 설계 팁 (UIKit호환)
최근 프로젝트에서 SwiftUI와 UIKit을 혼용해서 사용하는 빈도가 늘어남.
최신 생성되는 디자인 컴포넌트는 SwiftUI를 기반으로 하되, UIKit을 지원하는 방향으로 설계 SwiftUI를 사용할 때는 View에 상태를 저장해서 Cell 등에서 재사용 될 때 문제가 없도록 하는게 중요함.
회사 서비스가 iOS 15를 최소 버전으로 지원하므로 이에 맞추어 설계
목차
- 스크린샷
- 세부 설계
- 컴포넌트 코드 (View / Store / Reducer)
- 설계 중점 포인트
- SwiftUI 사용법
- UIKit 사용법
스크린샷
돋보기 아이콘, placeholder, 입력 중에만 나타나는 clear 버튼을 가진 검색 필드를 만들어서 사용 색상과 placeholder는 초기 상태로 주입받고, 상태 변화를 추적하며, 이벤트는 외부에도 전달 가능해야 함.


세부 설계
상태와 로직은 Store와 Reducer가 갖고, View는 표시만 하며, 상위로는 delegate로만 이벤트를 내보냄.
| 계층 | 타입 | 역할 |
|---|---|---|
| View | SearchFieldView (public) |
화면 표시. Store를 @StateObject로 소유하고, 입력을 store.send(_:)로 넘김 |
| Store | SearchFieldStore (@MainActor) |
상태 보유. send로 Action을 받아 Reducer를 돌리고, 결과 Effect를 실행 |
| Reducer | SearchFieldReducer (순수 값 타입) |
상태 변경 로직. (State, Action) -> [Effect] 변환만 담당, 부수효과는 실행하지 않음 |
흐름은 단방향으로 설계.
- 사용자 입력은 View에서
store.send(Action)하나로만 들어감. - Store는
reduce(into:&state, action:)로 Reducer에 넘김. - Reducer는 상태를 바꾸고 실행할
[Effect]를 반환.- 배열이라 순서대로 반환되며 병렬 작업이 필요한 경우에는 외부에서 동시성 처리
- Store는 바뀐
state를 발행해 View가 다시 그리게 하고,Effect.notify를 해석해 delegate로 상위에 전파
컴포넌트 코드 (View / Store / Reducer)
View: 외부에 공개되는 컴포넌트
import SwiftUI
/// 리스트에서 사용하는 검색 필드 (UI 모듈이 외부에 제공하는 public 컴포넌트)
public struct SearchFieldView: View {
/// 상태 관리를 위한 Store (내부 구현 → 외부 모듈엔 감춰짐)
@StateObject private var store: SearchFieldStore
public struct InitialState {
/// 초기 검색어 (컴포넌트 최초 생성 시 시드 값)
public var text: String
/// 플레이스홀더 기본 값
public var placeholderText: String
/// 색상
public var appearance: Appearance
// public struct의 자동 memberwise init은 internal이라 외부 모듈에서
// 사용 불가 → 명시적 public init 제공.
public init(
text: String = "",
placeholderText: String,
appearance: Appearance
) {
self.text = text
self.placeholderText = placeholderText
self.appearance = appearance
}
public struct Appearance {
public var magnifyingglassImage: Color
public var xmarkImage: Color
public var textFieldBackground: Color
public var textFieldAccent: Color
public var placeHolderText: Color
public init(
magnifyingglassImage: Color,
xmarkImage: Color,
textFieldBackground: Color,
textFieldAccent: Color,
placeHolderText: Color
) {
self.magnifyingglassImage = magnifyingglassImage
self.xmarkImage = xmarkImage
self.textFieldBackground = textFieldBackground
self.textFieldAccent = textFieldAccent
self.placeHolderText = placeHolderText
}
}
}
public enum DelegateAction: Sendable {
/// 전체 지우는 버튼 탭
case clearButtonTapped
/// 검색 단어가 변화했을 때
case onChangeSearchText(oldValue: String, newValue: String)
/// 텍스트 필드 onSubmit
case onSubmitTextField
}
public init(
initialState: InitialState,
delegate: ((DelegateAction) -> Void)? = nil
) {
_store = StateObject(
wrappedValue: SearchFieldStore(initialState: initialState, delegate: delegate)
)
}
public var body: some View {
HStack {
Image(systemName: "magnifyingglass")
.foregroundStyle(store.appearance.magnifyingglassImage)
ZStack(alignment: .leading) {
if !store.hasText {
Text(store.placeholderText)
.foregroundStyle(store.appearance.placeHolderText)
}
// 텍스트 변경은 store.searchText 바인딩의 set → send(.searchTextChanged)로 흐름.
TextField("", text: store.searchText)
.tint(store.appearance.textFieldAccent)
.onSubmit {
store.send(.submit)
}
}
if store.hasText {
Button {
store.send(.clearButtonTapped)
} label: {
Image(systemName: "xmark.circle")
.foregroundStyle(store.appearance.xmarkImage)
}
}
}
.padding(.horizontal, 12)
.frame(height: 40)
.background(
RoundedRectangle(cornerRadius: 12)
.foregroundStyle(store.appearance.textFieldBackground)
)
.padding(.horizontal, 12)
.padding(.vertical, 8)
}
}
Store: 상태 보유와 이벤트 구동
import SwiftUI
import Combine
/// SearchField의 런타임 스토어.
/// State를 보유하고, send(_:)로 Action을 받아 Reducer를 돌린 뒤,
/// 반환된 Effect를 해석(부모로 delegate 발행)하고 State 변경을 발행함.
/// SearchFieldView가 @StateObject로 소유 → 스크롤 등 뷰 재생성에도 상태 보존.
///
/// @Published 상태 변경이 항상 메인 스레드임을 보장하도록 @MainActor 명시.
/// (Reducer는 순수 값 타입이라 nonisolated 유지)
@MainActor
final class SearchFieldStore: ObservableObject {
/// 관리 상태: 변경은 오직 reduce를 통해서만 일어남(send가 유일한 입구)
@Published private(set) var state: SearchFieldReducer.State
/// 표현 설정(불변)
let initialState: SearchFieldView.InitialState
private let reducer = SearchFieldReducer()
/// 출력 포트: Effect를 바깥으로 전달
private let delegate: ((SearchFieldView.DelegateAction) -> Void)?
init(
initialState: SearchFieldView.InitialState,
delegate: ((SearchFieldView.DelegateAction) -> Void)? = nil
) {
self.initialState = initialState
self.delegate = delegate
self.state = .init(searchText: initialState.text)
}
// MARK: - View 가독성용 편의 접근자
var hasText: Bool { state.hasText }
var placeholderText: String { initialState.placeholderText }
var appearance: SearchFieldView.InitialState.Appearance { initialState.appearance }
/// 검색어 양방향 바인딩.
/// set이 send(_:)를 통과하므로 상태 변경 경로가 reduce로 일원화되고,
/// 입력값을 그대로(verbatim) 반영하므로 한글 IME 조합도 안전함.
var searchText: Binding<String> {
Binding(
get: { self.state.searchText },
set: { self.send(.searchTextChanged($0)) }
)
}
// MARK: - 단일 진입점
func send(_ action: SearchFieldReducer.Action) {
let effects = reducer.reduce(into: &state, action: action)
effects.forEach(handle)
}
private func handle(_ effect: SearchFieldReducer.Effect) {
switch effect {
case let .notify(delegateAction):
delegate?(delegateAction)
}
}
}
Reducer: 순수 로직
import Foundation
/// SearchField 도메인의 순수 로직 계층.
/// 상태를 보유하지 않고 (State, Action) -> [Effect] 변환만 담당하는 값 타입.
/// 부수효과(delegate 호출 등)는 직접 실행하지 않고 Effect로 기술만 하며,
/// 실제 실행은 Store가 담당. 덕분에 reduce는 순수 함수라 테스트가 쉬움.
struct SearchFieldReducer: Sendable {
/// 이 기능이 관리하는 상태
struct State: Sendable {
var searchText: String
/// 파생 상태 (placeholder / clear 버튼 노출 판단)
var hasText: Bool { !searchText.isEmpty }
}
/// View에서 도메인으로 들어오는 입력 인텐트
enum Action: Sendable {
case searchTextChanged(String)
case submit
case clearButtonTapped
}
/// 도메인에서 바깥으로 나가는 출력
enum Effect: Sendable {
case notify(SearchFieldView.DelegateAction)
}
/// 순수 함수: 상태를 변형하고 실행할 Effect 목록을 반환.
func reduce(into state: inout State, action: Action) -> [Effect] {
switch action {
case let .searchTextChanged(newValue):
let oldValue = state.searchText
state.searchText = newValue
return [.notify(.onChangeSearchText(oldValue: oldValue, newValue: newValue))]
case .submit:
return [.notify(.onSubmitTextField)]
case .clearButtonTapped:
state.searchText = ""
return [.notify(.clearButtonTapped)]
}
}
}
설계 중점 포인트
단방향 흐름과 단일 진입점
모든 사용자 입력은
store.send(_:)하나로 들어오고, 상태 변경은reduce안에서만 일어남.
들어오는 입력은 Action, 나가는 출력은 DelegateAction으로 이름을 나눠 방향을 명확히 함. 입력 경로가 하나뿐이라 상태가 어디서 바뀌는지 추적하기 쉬움.
상태를 Store가 소유하는 이유
재사용 컴포넌트가 스크롤로 재생성돼도 검색어가 유지되도록, 상태를 View 바깥의 Store가 소유.
SearchFieldView는 struct라 자주 재생성되지만, @StateObject로 소유한 Store는 뷰 identity가 유지되는 한 살아남음.
순수 Reducer와 테스트 용이성
Reducer는 상태를 보유하지 않는 값 타입이라 UI도 Store도 없이 로직만 검증할 수 있음.
Effect를 직접 실행하지 않고 Effect로 기술만 하므로, reduce를 직접 호출해 상태 변화와 방출된 Effect를 확인하면 됨. Reducer가 nonisolated 순수 값 타입이라 메인 액터 밖(테스트 컨텍스트)에서도 그대로 호출됨.
import Testing
@testable import StreamingLab
struct SearchFieldReducerTests {
private let reducer = SearchFieldReducer()
@Test func clearButtonTapped는_텍스트를_비우고_이벤트를_방출함() {
var state = SearchFieldReducer.State(searchText: "swift")
let effects = reducer.reduce(into: &state, action: .clearButtonTapped)
#expect(state.searchText == "")
#expect(effects.count == 1)
guard case .notify(.clearButtonTapped) = effects.first else {
Issue.record("clearButtonTapped 이벤트가 방출되지 않음")
return
}
}
@Test func searchTextChanged는_상태를_갱신하고_old_new를_전달함() {
var state = SearchFieldReducer.State(searchText: "sw")
let effects = reducer.reduce(into: &state, action: .searchTextChanged("swi"))
#expect(state.searchText == "swi")
guard case let .notify(.onChangeSearchText(oldValue, newValue)) = effects.first else {
Issue.record("onChangeSearchText 이벤트가 방출되지 않음")
return
}
#expect(oldValue == "sw")
#expect(newValue == "swi")
}
@Test func submit은_상태변화_없이_이벤트만_방출함() {
var state = SearchFieldReducer.State(searchText: "swift")
let effects = reducer.reduce(into: &state, action: .submit)
#expect(state.searchText == "swift")
guard case .notify(.onSubmitTextField) = effects.first else {
Issue.record("onSubmitTextField 이벤트가 방출되지 않음")
return
}
}
}
상위로의 출력은 delegate, 실행은 Store
Reducer가 무엇을 내보낼지 데이터로 결정하고, Store가 그것을 실행하며, View는 계약만 정의함.
Reducer는 Effect.notify(...)로 “이런 이벤트를 내보내야 함”을 데이터로만 남김. 실제 delegate 클로저 호출은 Store가 handle(_:)에서 수행함. Reducer가 delegate를 직접 부르면 순수성이 깨지므로 하지 않음. View는 DelegateAction 타입과 init 파라미터라는 공개 계약만 제공함.
접근제어: 외부 모듈 공개
SearchFieldView와 공개 API만public,Store와Reducer는internal로 감춤.
주의할 점은 public struct의 자동 memberwise init이 internal이라는 것. 외부 모듈에서 InitialState나 Appearance를 생성하려면 명시적 public init을 직접 제공해야 함.
Swift 6 동시성
Store는
@MainActor, Reducer는 nonisolated 순수 값 타입으로 역할에 맞게 격리함.
@MainActor 덕분에 @Published 상태 변경이 항상 메인 스레드에서 일어남. Reducer는 nonisolated 순수 값 타입이라 어느 스레드에서 호출해도 안전하고(그래서 위 테스트도 메인 밖에서 돎), 도메인 값 타입(State / Action / Effect / DelegateAction)은 모두 Sendable이라 액터 경계를 넘겨도 안전함. strict concurrency complete를 통과함.
한글 IME 안전
텍스트 변경 바인딩은 입력값을 그대로 반영해 한글 조합이 끊기지 않게 함.
store.searchText 바인딩의 set은 send(.searchTextChanged)를 통과하되 입력값을 변형 없이(verbatim) 반영함. 커스텀 Binding(get:set:)에서 값을 가공하면 한글 조합 중 글자가 깨질 수 있어 피함.
기타
- 강조색은 deprecated된
.accentColor대신.tint를 사용함.
SwiftUI 사용법
- 초기 상태는
initialState로 주입하고,@Binding대신 delegate 클로저로 이벤트를 처리함. - 검색어의 source of truth는 컴포넌트 내부 Store가 가지므로, 부모는 delegate로 받은 이벤트로 화면 로직(필터링 등)만 담당함.
struct SearchListView: View {
private let items = ["Swift", "SwiftUI", "UIKit", "Combine", "Concurrency"]
@State private var query = ""
private var filtered: [String] {
query.isEmpty ? items : items.filter { $0.localizedCaseInsensitiveContains(query) }
}
var body: some View {
VStack(spacing: 0) {
SearchFieldView(
initialState: .init(
placeholderText: "검색어를 입력하세요",
appearance: .init(
magnifyingglassImage: .gray,
xmarkImage: .gray,
textFieldBackground: Color(.secondarySystemBackground),
textFieldAccent: .accentColor,
placeHolderText: .secondary
)
)
) { action in
switch action {
case .clearButtonTapped:
query = ""
case let .onChangeSearchText(_, newValue):
query = newValue
case .onSubmitTextField:
break // 키보드 검색 완료 시 처리
}
}
List(filtered, id: \.self) { item in
Text(item)
}
.listStyle(.plain)
}
}
}
UIKit 사용법
- SwiftUI로 만든
SearchFieldView를UIHostingController로 감싸 UIKit 뷰 계층에 그대로 삽입함. - SwiftUI와 같은 delegate 규칙으로, 컴포넌트의 이벤트를 UIKit 화면의
viewModel로 넘김. 어느 쪽에서 쓰든 사용 방식이 같음.
import UIKit
import SwiftUI
final class SearchListViewController: UIViewController {
private let viewModel = SearchListViewModel()
override func viewDidLoad() {
super.viewDidLoad()
let searchField = SearchFieldView(
initialState: .init(
placeholderText: "검색어를 입력하세요",
appearance: .init(
magnifyingglassImage: .gray,
xmarkImage: .gray,
textFieldBackground: Color(.secondarySystemBackground),
textFieldAccent: .accentColor,
placeHolderText: .secondary
)
)
) { [weak self] action in
// SwiftUI 컴포넌트의 이벤트를 UIKit viewModel로 전달
switch action {
case .clearButtonTapped:
self?.viewModel.clear()
case let .onChangeSearchText(_, newValue):
self?.viewModel.updateQuery(newValue)
case .onSubmitTextField:
self?.viewModel.submit()
}
}
// UIHostingController로 UIKit 뷰 계층에 삽입
let hosting = UIHostingController(rootView: searchField)
addChild(hosting)
hosting.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(hosting.view)
NSLayoutConstraint.activate([
hosting.view.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
hosting.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
hosting.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
])
hosting.didMove(toParent: self)
}
}'Project > 개발 업무' 카테고리의 다른 글
| UICollectionView Crashes on iOS 18 with Xcode 16: Troubleshooting Guide (0) | 2024.11.22 |
|---|---|
| Combine ReadOnly Publisher (0) | 2024.11.20 |
| [Xcode 16 Beta] Could not download and install iOS 18.0 Simulator runtime with Xcode 16.0 beta (0) | 2024.06.12 |
| (Xcode 15.0 beta) Preview Macro Bug (0) | 2023.06.08 |
| Lottie 리소스 문제로 앱이 초기화되는 현상 (0) | 2022.07.12 |