
-
코딩 실습: AttributedString으로 SwiftUI에서 풍부한 텍스트 경험 만들기
SwiftUI의 TextEditor API 및 AttributedString를 사용하여 리치 텍스트 환경을 구축하는 방법을 알아보세요. 리치 텍스트 편집을 활성화하고, 편집자의 콘텐츠를 조작하는 맞춤형 컨트롤을 빌드하며, 사용 가능한 서식 옵션을 사용자 정의하는 방법을 알아볼 수 있습니다. 최고의 텍스트 편집 환경을 지원하는 AttributedString의 고급 기능을 살펴보세요.
챕터
- 0:00 - 서론
- 1:15 - TextEditor 및 AttributedString
- 5:36 - 맞춤형 컨트롤 빌드하기
- 22:02 - 텍스트 형식 정의하기
- 34:08 - 다음 단계
리소스
관련 비디오
WWDC25
WWDC22
WWDC21
-
비디오 검색…
안녕하세요, 저는 SwiftUI 팀의 엔지니어 Max입니다 저는 Swift 표준 라이브러리 팀의 엔지니어 Jeremy입니다 오늘은 SwiftUI 및 AttributedString을 활용해 리치 텍스트 편집 경험을 빌드하는 방법을 소개해 드리겠습니다 저는 Jeremy의 도움을 받아 SwiftUI의 리치 텍스트 경험이 지닌 중요한 측면을 모두 다룹니다 먼저 AttributedString을 사용해 리치 텍스트를 지원하는 TextEditor 업그레이드를 설명하고 맞춤형 컨트롤을 빌드해 편집기에 고유한 기능을 추가합니다 마지막으로 편집기와 콘텐츠가 항상 보기 좋도록 자체 텍스트 서식 지정 정의를 만들어 봅니다
저는 엔지니어이기도 하지만 완벽한 크루아상을
만들고 싶은 요리인이기도 한데요 최근 저는 제 요리 내용을 기록할 간단한 레시피 편집기를 만들고 있습니다
왼쪽에는 레시피 목록이 있고 중간에는 레시피 텍스트를 편집할 TextEditor가 있고 오른쪽에는 인스펙터에 재료 목록이 있습니다 레시피 텍스트의 가장 중요한 부분을 눈에 띄게 만들고 싶습니다 요리하는 동안 한눈에 쉽게 보이게요 이를 위해 편집기를 업그레이드해 리치 텍스트를 지원하게 해 보죠
이것이 SwiftUI의 TextEditor API로 구현한 편집기 보기입니다 텍스트 상태의 유형인 String에서 알 수 있듯이 현재 일반 텍스트만 지원하고요 String을 AttributedString으로 변경하면
보기의 기능이 극적으로 늘어납니다
이제 리치 텍스트가 지원되므로 시스템 UI를 사용해 볼드체를 토글하고 서체 크기 증가 등 다른 각종 서식을 적용할 수 있습니다
이제 키보드로도 젠모지를 삽입할 수 있고
SwiftUI의 서체 및 색상 속성이 지닌 의미적 특성 덕분에 새로운 TextEditor는 다크 모드와 Dynamic Type 또한 지원할 수 있죠
실제로 TextEditor는 볼드체, 이탤릭체 밑줄, 취소선, 맞춤형 서체 포인트 크기, 전경 및 배경 색상 커닝, 추적, 기준선 오프셋 젠모지 그리고 문단 스타일의 중요한 측면도 지원합니다 SwiftUI의 독립적인 별도 AttributedString 속성으로 줄 높이, 텍스트 정렬, 기본 쓰기 방향을 사용할 수 있습니다
이러한 모든 속성은 SwiftUI의 편집 불가능한 Text와 같으므로 TextEditor의 내용을 그대로 가져가 나중에 Text로 표시할 수 있습니다 Text와 마찬가지로 TextEditor는 값이 nil인 AttributedStringKey에 대해 환경에서 계산한 기본값을 대체합니다 Jeremy, 솔직히 말씀드릴게요 지금까지 AttributedString으로 작업하는 방법을 설명했는데 제 지식이 모두 정확한지 복습한 후에 컨트롤 빌드로 넘어가면 더 좋을 것 같아요 게다가 크루아상 반죽도 만들어야 하니 잠시 저를 대신해 내용 복습을 진행해 주시겠어요? 물론이죠, Max 크루아상 반죽을 준비하시는 동안 리치 텍스트 편집기로 작업할 때 유용한 AttributedString의 기본 사항을 설명해 보겠습니다 간단히 말해 AttributedString에는 일련의 문자와 일련의 속성 실행이 포함됩니다 예를 들어 이 AttributedString은 “Hello! Who’s ready to get cooking?” 텍스트를 저장합니다 또한 세 가지 속성 실행이 있는데 서체만 사용한 시작 실행 추가 전경 색상이 적용된 실행 서체 속성만 사용한 최종 실행입니다
AttributedString 유형은 앱에서 사용하는 다른 Swift 유형과 함께 활용할 수 있습니다 표준 라이브러리의 String처럼 값 유형이며 UTF-8 인코딩을 사용해 내용을 저장합니다 AttributedString은 Equatable Hashable, Codable, Sendable 등 여러 일반적인 Swift 프로토콜도 준수합니다 Apple SDK는 Max가 앞서 설명했듯이 사전 정의된 각종 속성이 속성 범위로 그룹화되어 함께 제공됩니다 AttributedString은 UI에 맞게 개인화된 스타일을 위해 앱에서 정의된 맞춤형 속성도 지원합니다
코드 몇 줄을 사용해 앞서 나온 AttributedString을 생성해 보죠 먼저 텍스트의 시작 부분으로 AttributedString을 만듭니다 이어서 “cooking” 문자열을 만들고 전경 색상을 orange로 설정한 후 텍스트에 추가합니다 그런 다음 끝에 물음표를 붙여 문장을 완성합니다 마무리로 전체 텍스트의 서체를 largeTitle 서체로 설정합니다 이제 기기의 UI에 표시할 준비가 되었습니다
AttributedString 생성의 기본 사항과 자체 맞춤형 속성 및 속성 범위의 생성 및 사용에 대한 자세한 내용은 2021년 WWDC의 Foundation의 새로운 기능 세션을 확인하세요
Max, 반죽을 다 만드신 것 같은데요 레시피 앱에서 AttributedString 사용을 살펴볼 준비가 되셨나요? 네 Jeremy 지금 진행하면 딱 맞겠어요 텍스트 편집기를 앱의 나머지 부분과 연결하는 데 더 효과적인 컨트롤을 마련하고자 했는데요 재료 이름을 수동으로 다시 입력할 필요 없이 오른쪽 인스펙터의 목록에 재료를 추가할 수 있는 버튼을 빌드하고 싶습니다 예를 들어 이미 레시피에 있는 “butter”라는 단어를 선택해 버튼을 한 번 누르면 재료로 표시되도록 하고 싶습니다
인스펙터는 편집기에서 목록의 새 재료를 제안하는 데 사용할 수 있는 선호 사항 키를 이미 정의합니다
제가 선호 사항 보기 수정자에 전달하는 값은 보기 계층 구조를 버블업하며 레시피 편집기 사용 보기에서 “NewIngredientPreferenceKey” 이름을 통해 읽을 수 있습니다
이 값에 대해 계산된 속성을 보기 본문 아래에 정의하겠습니다
제안에 이름을 AttributedString으로 제공하면 됩니다 물론 편집기에 있는 전체 텍스트를 제안하려는 것은 아닙니다 대신 앞서 “butter”처럼 현재 선택된 텍스트를 제안하고 싶습니다 TextEditor는 선택을 선택 사항인 선택 인수를 통해 전달하죠
AttributedTextSelection 유형의 로컬 상태에 연결하겠습니다
이제 필요한 모든 컨텍스트가 보기에 준비되었으니 재료 제안을 계산하는 속성으로 돌아가겠습니다
이제 선택된 텍스트를 가져와야 합니다
선택에 대한 이 indices 함수의 결과로 텍스트를 서브스크립팅해 보죠
올바른 유형이 아닌 것 같군요
AttributedTextSelection.Indices를 반환하는데요 한번 찾아보겠습니다
흥미롭네요 선택 사항이 하나인데 Indices 유형의 두 번째 사례가 범위 세트로 표현됩니다
Jeremy, 제가 크루아상 반죽을 접어 놓는 동안 이유를 설명해 주시겠어요? 재미있네요 맛있는 크루아상을 기대하며 저도 배가 반으로 접힐 지경이거든요 하지만 걱정 마세요, Max 이 API가 왜 예상한 Range 유형을 사용하지 않는지 설명하겠습니다 이 API가 왜 단일 Range가 아니라 RangeSet를 사용하는지 AttributedString 선택을 보죠 여러 범위가 선택을 형성하는 방법을 살펴보고 코드에서 RangeSet를 사용하는 방법을 보여 드리겠습니다 이전에 AttributedString API에서 단일 범위나 다른 컬렉션 API를 사용한 적이 있으실 텐데요 단일 범위를 사용하면 AttributedString의 일부를 슬라이스해 이 단일 청크에 동작을 수행할 수 있습니다 예를 들어 AttributedString은 일부 텍스트에 한 번에 속성을 빠르게 적용할 수 있는 API를 제공합니다 .range(of:) API를 사용해 AttributedString에서 “cooking” 텍스트의 범위를 찾았습니다 이어서 해당 범위로 AttributedString을 슬라이스해 “cooking”을 주황색으로 만들고자 서브스크립트 연산자를 사용합니다
하지만 하나의 범위로 슬라이스한 AttributedString으로는 모든 언어로 작동하는 텍스트 편집기에서 선택을 나타내기에 부족합니다 명절에 요리할 수프가니요트 레시피를 저장하는 데 이 레시피 앱을 사용하는데 히브리어 텍스트가 있다고 해 보죠 레시피에 “Put the Sufganiyot in the pan”이라고 적혀 있습니다 지침이 영어 텍스트로 되어 있는데 음식의 전통 이름은 히브리어 텍스트입니다 텍스트 편집기에서 단어 “Sufganiyot”의 일부와 단어 “in”을 하나의 선택으로 선택합니다 하지만 AttributedString에서는 실제로 다중 범위입니다 영어는 왼쪽-오른쪽 방향 언어라 편집기가 문장을 시각적으로 왼쪽에서 오른쪽으로 배치합니다 하지만 히브리어 부분인 Sufganiyot는 반대로 배치됩니다 오른쪽-왼쪽 방향 언어니까요 이 텍스트의 양방향 특성은 화면의 시각적 레이아웃에 영향을 미치지만 AttributedString은 모든 텍스트를 일관된 순서로 저장합니다 이 순서는 제 선택을 두 범위로 나눕니다 단어 “Sufganiyot”의 시작과 단어 “in”에서 히브리어 텍스트 끝부분을 제외한 부분입니다 이것이 SwiftUI 텍스트 선택 유형에서 단일 범위가 아니라 다중 범위를 사용하는 이유입니다
양방향 텍스트에 대한 앱 현지화의 자세한 내용은 2022년 WWDC의 올바르게 구현하기 세션과 올해의 앱의 다국어 경험 개선하기 세션을 확인하세요
이러한 유형의 선택을 지원하고자 AttributedString은 RangeSet를 통한 슬라이스를 지원합니다 Max가 선택 API에서 알아차린 유형인데요 단일 범위로 AttributedString을 슬라이스할 수 있는 것처럼 RangeSet로 슬라이스해 불연속적인 하위 문자열을 생성할 수도 있죠 여기서는 문자 보기에 .indices(where:) 함수를 사용해 RangeSet를 생성하여 텍스트의 대문자를 모두 찾았습니다 이 슬라이스의 전경 색상을 파란색으로 설정하면 모든 대문자가 파란색이 되고 나머지 문자는 수정되지 않습니다 SwiftUI에는 AttributedString을 선택으로 직접 슬라이스하는 동등한 서브스크립트도 있습니다 Max, 크루아상 반죽을 다 접으셨다면 선택을 허용하는 서브스크립트 API를 사용해 코드의 빌드 오류를 해결할 수 있을 것 같군요 그럼 그렇게 해 보죠 선택으로 텍스트를 직접 서브스크립팅한 다음
불연속적 AttributedSubstring을 새 AttributedString으로 변환할 수 있습니다
멋지네요 이제 기기에서 실행하고 “butter”를 선택하면 SwiftUI가 자동으로 newIngredientSuggestion 속성을 호출해 새 값을 계산하여 앱의 나머지 부분에 버블업합니다 그러면 인스펙터가 자동으로 재료 목록 하단에 제안을 추가합니다 여기서 탭 한 번으로 재료 목록에 입력할 수 있습니다 이러한 기능을 통해 편집기를 멋진 경험으로 변모시킬 수 있죠
이 추가 기능이 만족스럽습니다 하지만 지금까지 Jeremy의 설명을 토대로 더 발전시킬 수 있겠어요 텍스트 자체에서 재료를 더 효과적으로 시각화하고 싶습니다 가장 먼저 필요한 것은 텍스트 범위를 재료로 표시하는 맞춤형 속성입니다 새 파일에 정의해 보겠습니다
이 속성의 Value는 해당 속성이 참조하는 재료의 ID입니다 이제 RecipeEditor 파일에서 IngredientSuggestion을 계산하는 속성으로 돌아갑니다
IngredientSuggestion을 사용해 클로저를 두 번째 인수로 제공할 수 있죠
이 클로저는 더하기 버튼을 눌러 재료가 목록에 추가되면 호출됩니다 이 클로저를 사용해 편집기 텍스트를 변형하여 이름이 등장하는 부분을 Ingredient 속성으로 표시합니다 새로 생성되어 클로저에 전달된 재료의 ID를 받습니다
다음으로 텍스트에서 제안된 재료 이름이 등장하는 모든 부분을 찾아야 합니다
AttributedString의 문자 보기에서 ranges(of:)를 호출하면 됩니다
이제 범위를 받았으니 각 범위에 대한 재료 속성 값을 업데이트할 수 있습니다
이미 정의한 IngredientAttribute의 짧은 이름을 사용하고 있고요
한번 해 보죠
새로운 것을 기대하지는 않습니다 맞춤형 속성에 연결된 서식이 없기 때문입니다 “yeast”를 선택해 더하기 버튼을 눌러 볼까요
잠깐, 왜 이러죠? 커서가 상단에서 하단으로 가네요 다시 해 보겠습니다
“salt”를 선택하고
더하기 버튼을 누릅니다 선택이 끝으로 이동해 버리네요 Jeremy, 이제 크루아상 반죽을 밀어야 해서 지금 디버깅할 수 없는데요 혹시 선택이 재설정되는 이유를 아시나요? 앱을 사용하는 요리인들에게 발생해서는 안 되는 상황이네요 일단 반죽을 밀고 계시죠 이 예상치 못한 동작은 제가 알아보겠습니다
상황과 해결 방법을 보여 드리기 위해 리치 TextEditor에서 사용하는 범위 및 텍스트 선택을 형성하는 AttributedString 인덱스를 자세히 설명하겠습니다
AttributedString.Index는 텍스트 내의 단일 위치를 나타냅니다 강력하고 성능이 우수한 디자인을 뒷받침하고자 AttributedString은 콘텐츠를 트리 구조로 저장하며 인덱스는 이 트리를 통해 경로를 저장합니다 이러한 인덱스가 SwiftUI에서 텍스트 선택의 구성 요소를 형성하는 만큼 앱의 예상치 못한 선택 동작은 트리에서 AttributedString 인덱스의 동작 방식에 기인하죠 AttributedString 인덱스로 작업할 때는 두 가지를 명심해야 합니다 첫째, AttributedString 변형은 모든 인덱스를 무효화합니다 변형의 경계 안에 있지 않은 것도 말입니다 상한 재료로는 제대로 요리할 수 없는 법입니다 AttributedString에 만료된 인덱스를 사용할 때도 마찬가지입니다 둘째, 인덱스가 생성된 기원인 AttributedString에만 해당 인덱스를 사용해야 합니다
이제 원리 설명을 위해 이전에 만든 예시 AttributedString의 인덱스를 살펴보겠습니다 말씀드렸듯이 AttributedString은 콘텐츠를 트리 구조로 저장하는데 그 트리를 단순화한 예가 이것입니다 트리를 사용하면 텍스트를 변형할 때 성능이 향상되고 대량의 데이터를 복사하는 것이 방지되죠
AttributedString.Index는 트리를 통해 참조된 위치에 경로를 저장하여 텍스트를 참조합니다 AttributedString은 이 저장된 경로로 인덱스에서 특정 텍스트를 빠르게 찾을 수 있지만 인덱스에 전체 AttributedString의 트리 레이아웃에 대한 정보가 포함되어 있다는 의미이기도 합니다 AttributedString을 변형하면 트리 레이아웃이 조정될 수 있습니다 그러면 이전에 기록된 경로가 무효화됩니다 해당 인덱스의 대상이 텍스트 내에 여전히 존재해도 말입니다
또한 두 AttributedString에 동일한 텍스트 및 속성 콘텐츠가 있어도 트리의 레이아웃이 달라 인덱스가 서로 호환되지 않을 수 있습니다
인덱스를 사용해 이러한 트리를 탐색하여 정보를 찾으려면 AttributedString의 보기 중 하나에서 인덱스를 사용해야 하죠 인덱스는 특정 AttributedString에 연계되지만 해당 문자열의 모든 보기에서 사용할 수 있습니다 Foundation은 텍스트 콘텐츠의 문자 또는 문자소 클러스터 각 문자를 구성하는 개별 유니코드 스칼라, 문자열의 속성 실행에
대한 보기를 제공합니다
문자 보기와 유니코드 스칼라 보기의 차이점에 대한 자세한 내용은 Swift 문자 유형에 대한 Apple의 개발자 문서를 확인하세요
NSString 등 Swift의 문자 유형을 사용하지 않는 다른 문자열형 유형과 인터페이스할 때 하위 수준 콘텐츠에 접근해야 하는 경우도 있습니다 AttributedString은 이제 텍스트의 UTF-8 스칼라와 UTF-16 스칼라 모두에 대한 보기도 제공합니다 이 두 가지 보기는 모든 기존 보기와 동일한 인덱스를 공유하죠
인덱스와 선택의 세부 사항을 설명했으니 이제 Max가 레시피 앱에서 겪은 문제를 다시 살펴보겠습니다 IngredientSuggestion의 onApply 클로저는 속성이 지정된 문자열을 변형하지만 선택의 인덱스를 업데이트하지 않습니다 SwiftUI는 이러한 인덱스가 더 이상 유효하지 않음을 감지하고 앱이 충돌하지 않도록 선택을 텍스트 끝으로 이동하는 것입니다 이를 해결하려면 텍스트를 변형할 때 AttributedString API를 사용해 인덱스 및 선택을 업데이트합니다
이것은 레시피 앱과 동일한 문제가 있는 코드를 단순화한 예입니다 먼저 텍스트에서 “cooking”의 범위를 찾습니다 그런 다음 “cooking”의 범위를 주황색 전경 색상으로 설정하고 문자열에 단어 “chef”를 삽입해 레시피 테마를 더해 줍니다
텍스트 변경 시 AttributedString 트리의 레이아웃이 변경될 수 있죠
문자열을 변형한 후 cookingRange 변수를 사용하면 유효하지 않습니다 앱이 충돌할 수도 있습니다 대신 AttributedString은 Range 또는 Range 배열, 그리고 제공된 AttributedString을 제자리에서 변형하는 클로저를 사용하는 transform 함수를 제공합니다
클로저의 끝에서 transform 함수가 새 인덱스로 제공된 범위를 업데이트하므로 결과로 얻은 AttributedString에서 범위를 올바르게 사용할 수 있습니다 AttributedString에서 텍스트가 이동했을 수 있지만 범위는 여전히 동일한 의미 위치를 가리키며 여기서는 단어 “cooking”입니다 SwiftUI는 또한 범위 대신 선택을 업데이트하는 동등한 함수를 제공합니다
와, Max, 크루아상의 모양이 잡혀 가고 있군요 앱으로 돌아갈 준비가 되셨다면 이 새로운 transform 함수로 코드를 손볼 수 있을 것 같아요 감사합니다 바로 제가 찾던 해답이네요 코드에 적용할 수 있는지 보죠
첫째, 이렇게 범위를 반복해서는 안 됩니다 마지막 범위에 도달할 때면 텍스트가 여러 번 변형되고 인덱스가 유효하지 않으니까요 이 문제를 방지하고자 먼저 Range들을 RangeSet로 변환합니다
그런 다음 이것으로 슬라이스해 루프를 제거합니다
그럼 전체가 한 번의 변경이 되고 각 변형 후에 나머지 범위를 업데이트할 필요가 없습니다
둘째, 변경하려는 범위 옆에 커서 위치를 나타내는 선택도 있습니다 이것이 변환된 텍스트와 항상 일치하도록 해야 합니다 AttributedString에 SwiftUI의 transform(updating:) 오버로드를 사용하면 됩니다
이제 텍스트가 변형될 때마다 선택이 바로 업데이트됩니다
제대로 되는지 볼까요 “milk”를 선택하면 목록에 나타나고요 추가했을 때 선택이 그대로 유지됩니다 한 번 더 확인차 키보드에서 Command+B를 눌러 보면 단어 “milk”가 정상적으로 볼드체로 바뀝니다
레시피 텍스트에 모든 정보가 준비되었으므로 이제 재료를 색상으로 강조하고 싶습니다 마침 TextEditor에 이를 위한 도구가 있습니다 속성이 지정된 텍스트 서식 지정 정의 프로토콜입니다 맞춤형 텍스트 서식 지정 정의는 텍스트 편집기가 반응하는 대상인 AttributedStringKey와 여기에 있는 값을 중심으로 삼습니다 AttributedTextFormattingDefinition 프로토콜을 준수하는 유형을 이미 여기 선언했습니다
기본적으로 시스템은 SwiftUIAttributes 범위를 사용하며 속성 값에 제약 조건을 두지 않습니다
레시피 편집기의 범위에서
전경 색상, 젠모지 맞춤형 재료 속성만 허용하고 싶습니다
레시피 편집기로 돌아와서 attributedTextFormattingDefinition 수정자를 사용해 SwiftUI의 TextEditor에 맞춤형 정의를 전달할 수 있습니다
이 변경 사항으로 TextEditor가 재료, 젠모지, 전경 색상을 허용합니다
다른 모든 속성은 이제 기본값을 가정합니다 전체 편집기의 기본값은 환경을 수정해 여전히 변경할 수 있습니다 이 변경 사항을 바탕으로 TextEditor가 이미 시스템 서식 지정 UI에 중요한 변경 내용을 적용했습니다 정렬, 줄 높이 또는 서체 속성을 변경하는 컨트롤이 더 이상 제공되지 않죠 각각의 AttributedStringKey가 범위에 포함되지 않으니까요 하지만 색상 컨트롤로 텍스트에 임의의 색상을 적용할 수 있습니다 현재 상황에 관련성이 떨어지는 색상일지라도 말입니다
이런, 우유가 없어졌네요
재료만 녹색으로 강조 표시하고 나머지에는 기본 색상을 사용하고 싶습니다 SwiftUI의 AttributedTextValueConstraint 프로토콜로 논리를 구현 가능하죠
RecipeFormattingDefinition 파일로 돌아와 제약 조건을 선언합니다
AttributedTextValueConstraint 프로토콜을 준수하도록 먼저 속하는 AttributedTextFormattingDefinition 범위를 지정한 다음 제한할 AttributedStringKey를 지정합니다 여기서는 전경 색상 속성입니다 속성을 제한하는 실제 논리는 constrain 함수에 있습니다 이 함수에서 AttributeKey 즉 전경 색상의 값을 타당한 값으로 설정합니다
여기서 논리는 재료 속성의 설정 여부를 기준으로 합니다
설정되었으면 전경 색상이 녹색
아니면 nil이어야 합니다
이는 TextEditor가 기본 색상을 대체해야 함을 나타냅니다
제약 조건을 정의했으니 AttributedTextFormattingDefinition 본문에 추가하면 됩니다
나머지는 SwiftUI가 모두 처리합니다 TextEditor는 텍스트의 모든 부분에 정의와 제약 조건을 자동으로 적용한 후 화면에 표시합니다
이제 모든 재료가 녹색입니다
흥미롭게도 TextEditor가 색상 컨트롤을 비활성화했습니다 전경 색상이 서식 지정 정의의 범위 내에 있는데도요 제가 추가한 IngredientsAreGreen 제약 조건 때문입니다 전경 색상은 이제 텍스트가 재료 속성으로 표시되었는지 여부가 기준입니다 TextEditor는 자동으로 AttributedTextValueConstraint를 조사해 잠재적 변경 사항이 현재 선택에 유효한지 판단합니다 예를 들어 “milk”의 전경 색상을 다시 흰색으로 설정했다고 해 보죠 이후에 IngredientsAreGreen 제약 조건을 실행하면 전경 색상이 다시 녹색으로 바뀝니다 잘못된 변경임을 TextEditor가 알고 컨트롤을 비활성화하는 거죠 값 제약 조건은 편집기에 붙여넣은 텍스트에도 적용됩니다 Command+C로 재료를 복사해 Command+V로 붙여넣을 때 맞춤형 재료 속성이 유지됩니다 CodableAttributedStringKey를 사용하면 다른 앱의 TextEditor에서도 작동합니다 양쪽 앱이 해당 속성을 AttributedTextFormattingDefinition에 나열한다면 말이죠
지금도 꽤 괜찮지만 여전히 개선할 점이 있습니다 커서를 재료 “milk”의 끝에 두고 문자를 삭제하거나 계속 입력하면 일반 텍스트처럼 동작합니다 그냥 녹색 텍스트처럼 보이죠 특정한 이름이 있는 재료가 아니라요 이 문제를 해결하고자 재료 속성 실행의 끝에서 입력해도 속성이 확장되지 않도록 하고 단어를 수정하면 전체 단어의 전경 색상이 즉시 재설정되도록 하고 싶습니다
Jeremy, 나중에 크루아상 하나 더 드린다고 약속하면 이 내용을 구현하는 작업을 도와주시겠어요? 음, 하나로 충분할지 모르겠군요 몇 개 더 주시면 도와 드리죠, Max 오븐에 크루아상을 구우시는 동안 이 문제를 해결하는 데 도움이 되는 API를 설명하겠습니다 Max가 보여 드린 서식 지정 정의 제약 조건을 사용해 각 텍스트 편집기가 표시할 수 있는 속성 및 특정 값을 제한할 수 있습니다 레시피 편집기의 이 새로운 문제를 해결하기 위해 AttributedStringKey 프로토콜은 AttributedString의 변경 사항과 관련해 값이 변형되는 방식을 제한하는 추가 API를 제공합니다 속성이 제약 조건을 선언하면 AttributedString은 항상 속성을 서로, 그리고 텍스트 콘텐츠와 일관되게 유지해 더 간결하고 성능이 우수한 코드로 예상치 못한 상태를 방지합니다 몇 가지 예를 들어 속성에 이러한 API를 사용할 수 있는 경우를 설명하겠습니다 먼저 AttributedString에서 다른 콘텐츠와 결합되는 값을 살펴보겠습니다 예를 들면 맞춤법 검사 속성인데요
여기서 맞춤법 검사 속성은 단어 “ready”의 철자가 틀렸음을 빨간색 점선 밑줄로 나타냅니다 텍스트에 맞춤법 검사를 수행한 후 맞춤법 검사 속성이 이미 검증한 텍스트에만 계속 적용되도록 해야 합니다 하지만 텍스트 편집기에서 계속 입력하면 기본적으로 기존 텍스트의 모든 속성을 삽입된 텍스트에서 상속합니다 제가 원하는 맞춤법 검사 속성이 아니죠 해결하고자 AttributedStringKey에 새 속성을 추가합니다 AttributedStringKey 유형에 대해 inheritedByAddedText 속성을 “false”로 선언하면 추가 텍스트가 이 속성 값을 상속하지 않습니다
이제 문자열에 새 텍스트를 추가해도 새 텍스트에 맞춤법 검사 속성이 포함되지 않습니다 그 단어의 철자를 아직 확인하지 않았으니까요 그런데 이 속성에 또 다른 문제가 있네요 철자가 잘못되었다고 표시된 단어의 중간에 텍스트를 추가하면 추가된 텍스트 아래에서 빨간색 선이 끊겨 어색합니다 이 단어의 철자가 틀렸는지 앱에서 아직 확인하지 않았으므로 이 단어에서 속성을 제거해 UI에 오래된 정보가 표시되는 것을 방지해야 합니다
이를 위해 AttributedStringKey 유형에 또 다른 속성을 추가합니다 invalidationConditions 속성이죠 이 속성은 텍스트에서 해당 속성의 실행을 제거해야 하는 상황을 선언합니다 AttributedString은 텍스트가 변경될 때와 특정 속성이 변경될 때의 조건을 제공하며 속성 키는 여러 조건에 따라 무효화될 수 있습니다 여기서는 이 속성 실행의 텍스트가 변경될 때마다 해당 속성을 제거해야 하므로 “textChanged” 값을 사용합니다
이제 속성 실행 중간에 텍스트를 삽입하면 전체 실행에서 속성이 무효화되어 UI에서 이러한 불일치 상태가 방지됩니다 두 API 모두 Max의 앱에서 재료 속성을 유효하게 유지할 수 있습니다 Max가 오븐 작업을 마무리 중인데 그 동안 속성 카테고리를 한 가지 더 보여 드리겠습니다 텍스트 섹션 전체에서 일관된 값이 필요한 속성입니다 예를 들어 문단 정렬 속성이죠
텍스트의 각 문단에 다른 정렬을 적용할 수 있지만 단일 단어는 문단의 나머지 부분과 다른 정렬을 사용할 수 없습니다 AttributedString 변형 중에 이 요구 사항을 강제 적용하고자 AttributedStringKey 유형에 runBoundaries 속성을 선언합니다 Foundation은 문단 가장자리 또는 특정 문자의 가장자리에 대한 실행 경계 제한을 지원합니다 여기서는 이 속성을 문단 경계에 제한되도록 정의하겠습니다 문단의 시작부터 끝까지 값이 일관되도록요
이제 이런 상황이 불가능해집니다 문단의 한 단어에 왼쪽 정렬 값을 적용하면 AttributedString이 자동으로 속성을 문단의 전체 범위로 확장합니다 또한 정렬 속성을 열거하면 AttributedString이 개별 문단을 열거합니다 연속되는 두 문단이 동일한 속성 값을 포함하더라도 말입니다 다른 실행 경계도 똑같이 동작하죠 AttributedString은 한 경계에서 다음 경계까지 값을 확장하고 열거된 실행이 실행 경계마다 중단되도록 합니다
와, Max, 크루아상에서 맛있는 냄새가 나는군요 크루아상을 오븐에 다 넣으셨다면 이들 API로 서식 지정 정의를 보완해 맞춤형 속성에 원하는 동작을 달성할 수 있을 것 같은데 어떠세요? 제게 필요한 바로 그 비밀 재료네요 크루아상을 오븐에 다 넣었으니 지금 해 보면 되겠어요
맞춤형 IngredientAttribute에서 선택 사항인 inheritedByAddedText 요구 사항을 false 값으로 구현하겠습니다 그러면 재료 뒤에 입력해도 확장되지 않겠죠
이어서 invalidationConditions를 textChanged로 구현하면 재료의 문자를 삭제했을 때 더 이상 인식되지 않을 것이고요
시험해 보죠 “milk”의 끝에 “y”를 추가해도 “y”가 더 이상 녹색이 되지 않고 “milk”의 문자를 삭제하면 전체 단어에서 재료 속성이 즉시 제거됩니다 AttributedTextFormattingDefinition을 기준으로 전경 색상 속성은 계속해서 맞춤형 속성의 동작을 완벽하게 따릅니다
Jeremy, 고맙습니다 앱이 멋지게 완성되었네요 별 말씀을요 그럼 약속하신 크루아상은… 걱정 마세요, 거의 다 됐습니다 이제 오븐을 감시해 주시겠어요? Luca가 크루아상을 훔쳐 갈까 걱정되어서요 아, Luca 그 분 말인가요 위젯과 크루아상을 무척 사랑하는 분이라고요 알겠습니다, 셰프님 Jeremy와 함께하기 전에 마지막으로 몇 가지 팁을 드리죠 제 앱은 샘플 프로젝트로 다운로드하실 수 있습니다 손실 없는 드래그 앤 드롭 또는 RTFD로 내보내기를 위한 SwiftUI의 Transferable Wrapper 사용과 Swift Data로 AttributedString 유지에 대한 자세한 내용을 확인할 수 있습니다 AttributedString은 Swift의 오픈 소스 Foundation 프로젝트에 속합니다 GitHub에서 구현 사항을 확인해 진화에 기여하거나 Swift 포럼의 커뮤니티와 소통하세요 또 새로운 TextEditor로 앱에 젠모지 입력 지원을 추가하기가 매우 쉬워졌으니 한번 고려해 보세요 이 API를 사용해 앱의 텍스트 편집을 어떻게 업그레이드하실지 기대됩니다 약간의 개선이 큰 변화를 만듭니다
음, 정말 맛있네요 참 리치한 맛, 감칠맛이 나죠
-
-
1:15 - TextEditor and String
import SwiftUI struct RecipeEditor: View { @Binding var text: String var body: some View { TextEditor(text: $text) } }
-
1:45 - TextEditor and AttributedString
import SwiftUI struct RecipeEditor: View { @Binding var text: AttributedString var body: some View { TextEditor(text: $text) } }
-
4:43 - AttributedString Basics
var text = AttributedString( "Hello 👋🏻! Who's ready to get " ) var cooking = AttributedString("cooking") cooking.foregroundColor = .orange text += cooking text += AttributedString("?") text.font = .largeTitle
-
5:36 - Build custom controls: Basics (initial attempt)
import SwiftUI struct RecipeEditor: View { @Binding var text: AttributedString @State private var selection = AttributedTextSelection() var body: some View { TextEditor(text: $text, selection: $selection) .preference(key: NewIngredientPreferenceKey.self, value: newIngredientSuggestion) } private var newIngredientSuggestion: IngredientSuggestion { let name = text[selection.indices(in: text)] // build error return IngredientSuggestion( suggestedName: AttributedString()) } }
-
8:53 - Slicing AttributedString with a Range
var text = AttributedString( "Hello 👋🏻! Who's ready to get cooking?" ) guard let cookingRange = text.range(of: "cooking") else { fatalError("Unable to find range of cooking") } text[cookingRange].foregroundColor = .orange
-
10:50 - Slicing AttributedString with a RangeSet
var text = AttributedString( "Hello 👋🏻! Who's ready to get cooking?" ) let uppercaseRanges = text.characters .indices(where: \.isUppercase) text[uppercaseRanges].foregroundColor = .blue
-
11:40 - Build custom controls: Basics (fixed)
import SwiftUI struct RecipeEditor: View { @Binding var text: AttributedString @State private var selection = AttributedTextSelection() var body: some View { TextEditor(text: $text, selection: $selection) .preference(key: NewIngredientPreferenceKey.self, value: newIngredientSuggestion) } private var newIngredientSuggestion: IngredientSuggestion { let name = text[selection] return IngredientSuggestion( suggestedName: AttributedString(name)) } }
-
12:32 - Build custom controls: Recipe attribute
import SwiftUI struct IngredientAttribute: CodableAttributedStringKey { typealias Value = Ingredient.ID static let name = "SampleRecipeEditor.IngredientAttribute" } extension AttributeScopes { /// An attribute scope for custom attributes defined by this app. struct CustomAttributes: AttributeScope { /// An attribute for marking text as a reference to an recipe's ingredient. let ingredient: IngredientAttribute } } extension AttributeDynamicLookup { /// The subscript for pulling custom attributes into the dynamic attribute lookup. /// /// This makes them available throughout the code using the name they have in the /// `AttributeScopes.CustomAttributes` scope. subscript<T: AttributedStringKey>( dynamicMember keyPath: KeyPath<AttributeScopes.CustomAttributes, T> ) -> T { self[T.self] } }
-
12:56 - Build custom controls: Modifying text (initial attempt)
import SwiftUI struct RecipeEditor: View { @Binding var text: AttributedString @State private var selection = AttributedTextSelection() var body: some View { TextEditor(text: $text, selection: $selection) .preference(key: NewIngredientPreferenceKey.self, value: newIngredientSuggestion) } private var newIngredientSuggestion: IngredientSuggestion { let name = text[selection] return IngredientSuggestion( suggestedName: AttributedString(name), onApply: { ingredientId in let ranges = text.characters.ranges(of: name.characters) for range in ranges { // modifying `text` without updating `selection` is invalid and resets the cursor text[range].ingredient = ingredientId } }) } }
-
17:40 - AttributedString Character View
text.characters[index] // "👋🏻"
-
17:44 - AttributedString Unicode Scalar View
text.unicodeScalars[index] // "👋"
-
17:49 - AttributedString Runs View
text.runs[index] // "Hello 👋🏻! ..."
-
18:13 - AttributedString UTF-8 View
text.utf8[index] // "240"
-
18:17 - AttributedString UTF-16 View
text.utf16[index] // "55357"
-
18:59 - Updating Indices during AttributedString Mutations
var text = AttributedString( "Hello 👋🏻! Who's ready to get cooking?" ) guard var cookingRange = text.range(of: "cooking") else { fatalError("Unable to find range of cooking") } let originalRange = cookingRange text.transform(updating: &cookingRange) { text in text[originalRange].foregroundColor = .orange let insertionPoint = text .index(text.startIndex, offsetByCharacters: 6) text.characters .insert(contentsOf: "chef ", at: insertionPoint) } print(text[cookingRange])
-
20:22 - Build custom controls: Modifying text (fixed)
import SwiftUI struct RecipeEditor: View { @Binding var text: AttributedString @State private var selection = AttributedTextSelection() var body: some View { TextEditor(text: $text, selection: $selection) .preference(key: NewIngredientPreferenceKey.self, value: newIngredientSuggestion) } private var newIngredientSuggestion: IngredientSuggestion { let name = text[selection] return IngredientSuggestion( suggestedName: AttributedString(name), onApply: { ingredientId in let ranges = RangeSet(text.characters.ranges(of: name.characters)) text.transform(updating: &selection) { text in text[ranges].ingredient = ingredientId } }) } }
-
22:03 - Define your text format: RecipeFormattingDefinition Scope
struct RecipeFormattingDefinition: AttributedTextFormattingDefinition { struct Scope: AttributeScope { let foregroundColor: AttributeScopes.SwiftUIAttributes.ForegroundColorAttribute let adaptiveImageGlyph: AttributeScopes.SwiftUIAttributes.AdaptiveImageGlyphAttribute let ingredient: IngredientAttribute } var body: some AttributedTextFormattingDefinition<Scope> { } } // pass the custom formatting definition to the TextEditor in the updated `RecipeEditor.body`: TextEditor(text: $text, selection: $selection) .preference(key: NewIngredientPreferenceKey.self, value: newIngredientSuggestion) .attributedTextFormattingDefinition(RecipeFormattingDefinition())
-
23:50 - Define your text format: AttributedTextValueConstraints
struct IngredientsAreGreen: AttributedTextValueConstraint { typealias Scope = RecipeFormattingDefinition.Scope typealias AttributeKey = AttributeScopes.SwiftUIAttributes.ForegroundColorAttribute func constrain(_ container: inout Attributes) { if container.ingredient != nil { container.foregroundColor = .green } else { container.foregroundColor = nil } } } // list the value constraint in the recipe formatting definition's body: var body: some AttributedTextFormattingDefinition<Scope> { IngredientsAreGreen() }
-
29:28 - AttributedStringKey Constraint: Inherited by Added Text
static let inheritedByAddedText = false
-
30:12 - AttributedStringKey Constraint: Invalidation Conditions
static let invalidationConditions: Set<AttributedString.AttributeInvalidationCondition>? = [.textChanged]
-
31:25 - AttributedStringKey Constraint: Run Boundaries
static let runBoundaries: AttributedString.AttributeRunBoundaries? = .paragraph
-
32:46 - Define your text format: AttributedStringKey Constraints
struct IngredientAttribute: CodableAttributedStringKey { typealias Value = Ingredient.ID static let name = "SampleRecipeEditor.IngredientAttribute" static let inheritedByAddedText: Bool = false static let invalidationConditions: Set<AttributedString.AttributeInvalidationCondition>? = [.textChanged] }
-
-
- 0:00 - 서론
이 섹션에서는 세션의 목표인 AttributedString을 사용하여 SwiftUI에서 리치 텍스트 편집 경험을 빌드하는 방법을 보여 줍니다. 발표자인 Max가 ‘TextEditor’를 업그레이드하여 리치 텍스트를 지원하고, 사용자 지정 제어 기능을 만들어 편집기를 향상하며, 사용자 지정 텍스트 포맷 정의를 생성하여 일관된 스타일을 보장하는 등 세 가지 주요 영역에 대해 설명합니다.
- 1:15 - TextEditor 및 AttributedString
이 섹션에서는 기본 데이터 유형을 ‘String’에서 ‘AttributedString’으로 변경하여 리치 텍스트를 지원하도록 SwiftUI ‘TextEditor’를 업그레이드하는 데 초점을 맞춥니다. 이렇게 하면 볼드체, 이탤릭체, 서체 크기, 색상, 젠모지와 같은 포맷 옵션에서 시스템 UI 제어 기능에 대한 지원을 즉시 활용할 수 있습니다. ‘TextEditor’는 단락 스타일을 포함하여 다양한 속성을 지원합니다. 또한 이 섹션에서는 구조(문자 및 속성 실행), 값 유형 특성, UTF-8 인코딩, 일반적인 Swift 프로토콜 준수를 비롯하여 ‘AttributedString’ 기본 사항을 빠르게 살펴봅니다. 미리 정의된 속성의 사용과 사용자 지정 속성을 정의하는 기능을 강조합니다.
- 5:36 - 맞춤형 컨트롤 빌드하기
이 섹션에서는 애플리케이션의 나머지 요소와 상호작용하는 ‘TextEditor’에 대한 사용자 지정 제어 기능을 구축하는 방법을 설명합니다. 사용자가 선택한 텍스트를 재료로 표시할 수 있도록 해 주는 버튼을 추가하는 방법을 보여 줍니다. 환경설정 키를 사용하여 편집기 및 UI의 다른 부분 간에 커뮤니케이션을 다룹니다. 또한 이 섹션에서는 ‘AttributedString’에 포함된 텍스트 선택의 복잡성을 자세히 살펴봄으로써 ‘AttributedTextSelection’ 유형이 단일 ‘Range’ 대신 ‘RangeSet’를 사용하여 양방향 텍스트 및 불연속적 선택을 처리하는 이유를 설명합니다. 또한 선택과 함께 AttributedString을 직접 분할하는 서브스크립트 API의 사용을 강조합니다. 마지막으로 이 섹션에서는 클로저를 사용하여 사용자 지정 ‘Ingredient’ 속성으로 선택된 텍스트와 일치하는 항목을 마크업하여 편집기 텍스트를 변경하는 방법을 보여 줍니다. 또한 속성이 있는 문자열을 변형한 후 선택 항목이 재설정되는 문제에 대해 논의하고 AttributedString 인덱스의 개념 그리고 변환 함수를 사용하여 변형 후 인덱스를 업데이트하는 것의 중요성을 소개합니다.
- 22:02 - 텍스트 형식 정의하기
이 섹션에서는 속성이 지정된 텍스트 포맷 정의 프로토콜을 사용하여 ‘TextEditor’에서 사용 가능한 포맷 옵션을 제어하는 데 초점을 맞춥니다. 편집자가 응답해야 하는 ‘AttributedStringKeys’를 지정하는 사용자 지정 포맷 정의를 생성하여 사용 가능한 포맷 옵션을 제한하는 방법을 설명합니다. 또한 재료는 항상 녹색으로 강조 표시되도록 하는 등 특정 포맷 규칙을 시행하기 위해 ‘AttributedTextValueConstraint’를 소개합니다. 이 섹션에서는 ‘AttributedStringKey’ 프로토콜을 사용하여 속성 값을 제약하는 방법을 추가로 설명합니다. 텍스트가 변형되는 동안 속성이 상속되고 무효화되는 방식을 제어하는 ‘inheritedByAddedText’ 및 ‘invalidationConditions’ 같은 속성을 다룹니다. 마지막으로 이 섹션에서는 단락과 같은 텍스트 섹션 전체에 일관된 값을 적용하기 위한 ‘runBoundaries’ 속성에 대해 설명합니다.
- 34:08 - 다음 단계
이 섹션에서는 최종 팁 및 리소스를 제공합니다. 무손실 드래그 앤 드롭, RTFD 내보내기, Swift Data로 지속되는 ‘AttributedString’을 보여 주는 샘플 프로젝트를 언급합니다. ‘AttributedString’이 Swift의 오픈 소스 파운데이션 프로젝트의 일부라는 사실을 강조하고 이에 대한 기여를 독려합니다. 또한 섹션에서는 개발자가 새로운 ‘TextEditor’를 사용하여 젠모지 지원을 추가하도록 독려합니다.