2021년 1월 2일 토요일

뷰와 컨트롤 — 여러분이 기다려온 SwiftUI 2 문서

 

뷰와 컨트롤 — 여러분이 기다려온 SwiftUI 2 문서 

iOS 14, iPadOS 14, WatchOS 7 및 MacOS Big Sur 용으로 업데이트 됨 

Pixabay로부터 입수 된 Albrecht Fietz의 일부 뷰 및 컨트롤 사진

2020 년 초, 저는 The Complete SwiftUI Documentation You'veen Waiting For 라는 긴 Medium 게시물을 썼습니다 .

이것이 애플이 제공하는 불충분 한 문서로 인해 남겨진 틈을 메우려 고 할 때 배운 것을 공유하는 방법이었습니다. 내 게시물이 많은 사람들에게 도움이되는 것 같았지만 6 개월 늦게 썼습니다.

이제 Apple의 2020 개발자 컨퍼런스가 끝났으므로 SwiftUI에 몇 가지 새로운 기능이 제공되었으므로이 업데이트가 내 문서가 그 어느 때보 다 도움이되기를 바랍니다.

이것은 포스트 당 하나의 챕터로 시리즈로 출시 될 것입니다.

이 장의 이름은 Apple의 SwiftUI 문서에있는 장 이름과 일치합니다.

나는 그들 중 누구도 이것만큼 길지 않을 것이라고 보장 할 수 있습니다.

The View Protocol 
@ViewBuilder
New and Updated Views
ColorPicker (NEW in 2.0)
SpriteView (NEW in 2.0)
TextEditor (NEW in 2.0)
SignInWithAppleButton (NEW in 2.0)
ProgressView (NEW in 2.0)
GaugeView (NEW in 2.0)
Label (NEW in 2.0)
Link (NEW in 2.0)
Menu (NEW in 2.0)
MenuButton (Deprecated in 2.0)
Text (Updated in 2.0)
Image (Updated in 2.0)
Button (Updated in 2.0)
PasteButton (Updated in 2.0)
Toggle (Updated in 2.0)
DatePicker (Updated in 2.0)
New and Updated View Modifiers
.matchedGeometryEffect (NEW in 2.0)
.help (NEW in 2.0)
.accessibility(inputLabels:) (NEW in 2.0)
.accessibility(selectionIdentifier:) (Deprecated in 2.0)
.scaleEffect (Updated in 2.0)
.imageScale (NEW in 2.0)
.accentColor (Updated in 2.0)
.preferredColorScheme (Updated in 2.0)
.textContentType (NEW in 2.0)
.listItemTint (NEW in 2.0)
.listRowPlatterColor (Deprecated in 2.0)
.onLongPressGesture (Updated in 2.0)
.onOpenURL (NEW in 2.0)
.onPasteCommand (NEW in 2.0)
.onDrag and .onDrop (Updated in 2.0)
.onChange (NEW in 2.0)
.keyboardShortcut (NEW in 2.0)
.focusedValue and @FocusedBinding (NEW in 2.0)
.prefersDefaultFocus and .focusScope (NEW in 2.0)
.fullScreenCover (NEW in 2.0)
.defaultAppStorage (NEW in 2.0)
.appStoreOverlay (NEW in 2.0)
.toolbar (NEW in 2.0)
.previewContext (NEW in 2.0)
.userActivity, .onContinueUserActivity (NEW in 2.0)
.tabItem (Updated in 2.0)
.contextMenu (Updated in 2.0)
.navigationTitle and .navigationSubtitle (NEW in 2.0)
.navigationViewStyle (Updated in 2.0)
.navigationBarTitle (Deprecated in 2.0)
.navigationBarItems (Deprecated in 2.0)
Styles on iOS, iPadOS, Mac Catalyst and tvOS (NEW in 2.0)
Styles Only on macOS (NEW in 2.0)
Next Steps

아직 모르는 경우 SwiftUI는 View프로토콜을 사용하여 재사용 가능한 인터페이스 요소를 만듭니다. 뷰는 그들이 사용하는 수단 값 유형입니다 Struct대신의 Class정의.

실제로 이것은 실제로 무엇을 의미합니까?

구조체는 상속을 허용하지 않습니다. 구조체는 View프로토콜 을 따르지만 ViewApple이 제공 한 기본 클래스에서 상속하지 않습니다 .

이것은 UIViewUIKit의 거의 모든 것이 상속되는. A는 UIView기본적으로 프레임이 할당되지 않고 UIViewController서브 클래스 의 서브 뷰로 추가되지 않으면 볼 수 없습니다 .

당신이 사용은 사용자 인터페이스의 기초로 대신 스토리 보드의 SwiftUI하는 새로운 Xcode 프로젝트를 생성하면 자동으로 SwiftUI의 예를 제공 할 것 View이라고 ContentView.

ContentView구조체 안에 body라는 변수가 있음을 알 수 있습니다 . 이것은 View프로토콜 의 유일한 요구 사항이며 someSwift 5.1의 새로운 키워드를 사용합니다 .

당신은 의지 할 수 스택 오버플로 스레드 더 나은 내가 할 수있는 것보다 어떻게이 키워드 수단을 설명하기 :

"이것을"역 "의 일반적인 자리 표시 자로 생각할 수 있습니다. 호출자가 만족하는 일반 제네릭 플레이스 홀더와는 달리 ... 불투명 한 결과 유형은 구현에 의해 충족되는 암시 적 제네릭 플레이스 홀더입니다. 여기서 가장 중요한 것은 반환하는 함수 some P가 특정 단일 콘크리트의 값을 반환하는 것입니다. 이 부합 함을 선언을하는 입력합니다 P. "

@ViewBuilder

이것은 여러 뷰에서 단일 뷰를 구성 할 수있는 일종의 함수 빌더입니다. 이 속성을 View 본문 위에 추가하고 cmd- 클릭하고 정의로 이동을 선택하면 흥미로운 내용이 많이 표시됩니다. 아마도 가장 중요한 부분은 다음과 같습니다.

public static func buildBlock<C0, C1, C2, C3, C4, C5, C6, C7, C8, C9>(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4, _ c5: C5, _ c6: C6, _ c7: C7, _ c8: C8, _ c9: C9) -> TupleView<(C0, C1, C2, C3, C4, C5, C6, C7, C8, C9)> where C0 : View, C1 : View, C2 : View, C3 : View, C4 : View, C5 : View, C6 : View, C7 : View, C8 : View, C9 : View

Xcode 12에서 body 속성은 .

이것이 왜 중요합니까? 이제 body 속성에 직접 자식으로 최대 10 개의 뷰를 넣을 수 있습니다. 이전에는 뷰 를 ,, 또는 의 레이아웃에 영향을주지 않고의 Group이점을 얻을 수있는 방법 인 . 이는 새 버전의 SwiftUI에서 더 이상 필요하지 않으며 그 결과 의 사용 이 더 틈새 시장이 될 것입니다.@ViewBuilderVStackHStackZStackGroup

뷰 를 사용하려고 할 때 body 속성에 직접 배치하는 것은 모호하므로 여전히 뷰를 VStack또는 HStack에 배치해야합니다.

수정 자보기

2020 년에 새로 추가 된보기에 대해 알아보기 전에보기 수정자가 무엇인지 다시 살펴 보겠습니다. 새로운 뷰와 함께 표시되므로 나중에 설명하기 위해 기다리는 것은 의미가 없습니다.

모든 뷰는 ViewModifier 프로토콜을 준수하는 구조체로 수정할 수 있습니다. 모든 프로토콜에 필요한 것은 일반 뷰를 반환하는 body (content : Content)라는 함수입니다. View를 직접 만들 수 없기 때문에 수정자가 호출 될 때까지 ViewModifier에 전달하는 유형을 알 수 없습니다. 콘텐츠는 구체적인 유형에 대한 프록시 역할을합니다. 뷰의 본문과 같은 반환 유형은 구현에서 유추됩니다.

다음과 같이 표면 아래에서 일어나는 일을 볼 수 있도록 사용자 지정 수정 자의 예를 살펴 보겠습니다.

import SwiftUI
struct ContentView: View {
var body: some View {
VStack {
Text("Blue")
.foregroundColor(.blue)
.padding(10)
Text("Blue")
.modifier(BluePaddingModifier(padding: 10))
Text("Blue")
.blue(10)
Text("Blue")
.b(10)
}
}
}
struct BluePaddingModifier: ViewModifier {
let padding: CGFloat
func body(content: Content) -> some View {
content
.foregroundColor(.blue)
.padding(padding)
}
}
extension View {
func blue(_ padding: CGFloat) -> some View {
let modifier = BluePaddingModifier(padding: padding)
return ModifiedContent(content: self, modifier: modifier)
}
func b(_ padding: CGFloat) -> some View {
self.modifier(BluePaddingModifier(padding: padding))
}
}

보시다시피 .modifier(YourModifier())ViewModifier를 호출 하기 위해 추가 하는 것이 가능 하지만 View확장 을 사용 하고 깨끗한 호출 사이트를 제공하는 것이 훨씬 더 합리적 입니다.

첫 번째 방법 인 blue(_:)은 기본적으로하는 일을 .modifier(YourModifier())합니다. 구성하여 ModifiedContent, 우리는 결국 View수식을 가지고 그 적용했다. 그러나 동일한 결과를 얻기 위해 인스턴스 메소드 .modifier(YourModifier())를 호출하여이를 덜 복잡하게 만들 수 View있습니다.

두 번째 메서드 b(_:)인는 필요할 .modifier(YourModifier())때마다 호출 할 필요가 없습니다. 이것이 표준 수정자가 보이는 방식이며 View.

분명히 b(_:)속성, 메서드 또는에 대한 꽤 끔찍한 이름 ViewModifier이지만 식별자를 점차 짧게 만들어 가장 간단한 식별자를 나타냅니다.

먼저 2020 년에 완전히 새로 워진 모든 뷰를 볼 수 있고, 그다음에 올해 업데이트 된 2019 년 뷰를 볼 수 있습니다.

그런 다음 마지막에 어떤 View Modifier가 새로 추가되거나 업데이트되었는지 확인합니다.

2.0의 새로운 기능 : ColorPicker

iOS 개발자를위한 색상 선택기가 포함 된 적이 없습니다. 이전에 타사 제품을 사용해 보았지만 종속성으로 인해 향후 모든 프로젝트 및 배포 대상과의 호환성을 보장하기 위해 다른 개발자에게 의존하게됩니다. WWDC 전 마지막 몇 주 동안 마침내 SwiftUI에서 생각할 수있는 모든 종류의 색상 선택기 컨트롤을 만들기로 결정했습니다. 이를 통해 이러한 컨트롤 의 Swift Package에서 색상 선택기  만들고 모든 프로젝트에 대한 색상 선택기를 만들 수 있습니다.

그런 다음 WWDC가 나왔고 이제 iOS 14 용 ColorPicker가 있습니다.이 새로운 컨트롤은 매우 유능 해 보이며 어디에서나 색상을 선택할 수있는 스포이드와 같은 기능이 있습니다. 스포이드를 사용하여 ColorPicker의 UI 자체에서 색상을 선택할 수도 있으므로 이것이 무료로 제공되는 강력한 새 기능이라는 것이 분명해 보입니다. 하지만 제가 해결할 수있는 한, 새로운 색상 선택기에는 변경할 수없는 매우 엄격한 컨트롤 세트가 있습니다. 공식 문서가 아직 업데이트되지 않은 경우 ColorPicker가 제공하는 내용을 캔버스, 팔레트 또는 슬라이더로만 제한하는 것과 같이 변경할 수있는 방법이없는 것처럼 보입니다.

대신이 세 가지 옵션은 사용자가 세그먼트 선택기로 선택합니다.

struct ColourPickerView: View {
@State private var bgColor =
Color(.sRGB, red: 0.98, green: 0.9, blue: 0.2)
var body: some View {
VStack {
ColorPicker("Alignment Guides", selection: $bgColor)
.frame(width: 500, height: 1000)
}
}
}
view rawColorPicker.swift hosted with ❤ by GitHub

ColorPicker가 앱에서 어떻게 보이는지에 대한 유용한 스크린 샷 및 애니메이션 GIF는 Using a ColorPicker with SwiftUI를 확인하십시오 .

2.0의 새로운 기능 : SpriteView

SpriteKit은 2D 게임 제작을위한 Apple의 프레임 워크입니다. 스프라이트는 무엇보다도 플레이어, 적, 발사체를 나타내는 데 사용되는 작은 비트 맵입니다. 게임에서 한 번에 화면에 많은 스프라이트가있을 수 있으므로 성능을 염두에두고이를 수행하도록 설계된 도구를 사용하는 것이 좋습니다. 이제 SpriteKit에서를 표시하는 SwiftUI 뷰 SKScene를 생성하여 게임을 생성 한 다음 해당 게임을 SwiftUI 뷰를 배치 할 위치에 둘 수 있습니다.

내 예에서는 오른쪽에서 오는 적에게 발사체를 발사하는 간단한 사각형 스프라이트가 있습니다. 그들이 왼쪽 끝까지 가면 당신은 생명을 잃습니다. 그중 하나를 쓰러 뜨리면 점수가 올라갑니다.

나중에 만들 SpriteKit 씬이 필요한 SwiftUI를 먼저 살펴 보겠습니다.

import SwiftUI
import SpriteKit
class GameModel: ObservableObject {
static let startLives = 5
static let startScore = 0
static let shared = GameModel()
@Published var lives = startLives
@Published var score = startScore
@Published var gameOver = false
@Published var restartGame = false {
didSet {
if restartGame {
gameOver = false
lives = GameModel.startLives
score = GameModel.startScore
}
}
}
}
struct ContentView: View {
@ObservedObject var data = GameModel.shared
@State var scene = GameScene(size: CGSize(width: 300, height: 400))
var body: some View {
VStack {
HUDView(data: data)
SpriteView(scene: self.scene)
.onChange(of: data.restartGame) { shouldRestart in
data.restartGame = false
scene.restart()
}
}
}
}
struct HUDView: View {
@ObservedObject var data: GameModel
@AppStorage("highScore") var highScore = 0
var body: some View {
HStack {
Text("Score: \(data.score)")
Text("High score: \(highScore)")
if self.data.gameOver {
Button("Restart") {
if data.score > highScore {
highScore = data.score
}
data.restartGame = true
}
}
Spacer()
Text("Lives: \(data.lives)")
}
}
}

데이터를 저장 하는 ObservableObject호출 GameModel ContentView게임을 표시 하는 구조체가 있습니다. 의 상단에는 현재 점수, 우리의 생명 수, 이전에 기록 된 최고 점수를 알려주는를 VStack표시합니다 HUDView. 수명이 다하면 다시 시작 버튼이 나타납니다. 이 모든 작업은 에서 new modifier로 관찰 @Published 중인 GameModel객체 의 속성을 변경하는 것 .onChange입니다 ContentView.

기본적으로 우리는 언제 restartGame가 참인지 말하고 GameScene, 게임을 처음부터 일시 중지를 해제하고 다시로드해야한다는 메시지를 우리에게 보내고 싶습니다 .

높은 점수는 편리한 방법으로 @AppStorage데이터를 저장 하는 속성 래퍼를 사용하여 기록됩니다 UserDefaults. 이 문서의 다른 장에서 새 속성 래퍼에 대한 자세한 정보를 제공 할 것이지만 중요한 것은이 래퍼가있는 속성이 지속적으로 저장되고 다음에 앱이로드 될 때 쉽게 불러올 수 있다는 것입니다. 플레이어가 생명이 없을 때 게임 오버 상태도 있습니다.이 상태는 플레이어가 게임을 다시 시작하기 위해 재시작 버튼을 탭해야합니다. 이것은 점수를 재설정하고, 적을 제거하고, 게임 시작과 마찬가지로 적의 스폰을 다시 시작합니다.

다음은 SpriteKit 코드입니다.

import SpriteKit
class GameScene: SKScene, SKPhysicsContactDelegate {
static let projectileSize = CGFloat(10)
static let playerSize = CGFloat(15)
static let enemySize = CGFloat(30)
let data = GameModel.shared
var playerPosition: CGPoint {
CGPoint(x: size.width * 0.1, y: size.height * 0.5)
}
override func didMove(to view: SKView) {
physicsWorld.contactDelegate = self
startGame()
}
func startGame() {
addWalls()
addPlayer()
run(SKAction.repeatForever(SKAction.sequence([SKAction.run(addEnemy), SKAction.wait(forDuration: 1.0)])))
}
func restart() {
removeAllChildren()
self.scene?.view?.isPaused = false
startGame()
}
func addPlayer() {
let player = SKSpriteNode(color: UIColor.red, size: CGSize(width: Self.playerSize, height: Self.playerSize))
player.position = playerPosition
addChild(player)
}
func addWalls() {
let wallFrames = [
CGRect(x: 0, y: frame.midY, width: 10, height: size.height),
CGRect(x: size.width, y: frame.midY, width: 10, height: size.height),
CGRect(x: frame.midX, y: size.height, width: size.width, height: 10)
]
wallFrames.forEach {
let wall = SKSpriteNode(color: UIColor.orange, size: $0.size)
wall.position = CGPoint(x: $0.minX, y: $0.minY)
wall.physicsBody = SKPhysicsBody(rectangleOf: CGSize(width: wall.size.width, height: wall.size.height))
wall.zPosition = 1
wall.physicsBody?.isDynamic = false
addChild(wall)
}
}
func didBegin(_ contact: SKPhysicsContact) {
guard let nodeA = contact.bodyA.node, let nodeB = contact.bodyB.node else { return }
if [nodeA.name, nodeB.name].contains("projectile") && [nodeA.name, nodeB.name].contains("enemy") {
[nodeA, nodeB].forEach {
$0.removeAllActions()
$0.name = "projectile"
$0.physicsBody?.affectedByGravity = true
$0.run(SKAction.sequence([SKAction.wait(forDuration: 2), SKAction.removeFromParent()]) )
}
self.data.score += 1
}
}
func random() -> CGFloat {
return CGFloat(Float(arc4random()) / 4294967296)
}
func random(min: CGFloat, max: CGFloat) -> CGFloat {
return random() * (max - min) + min
}
func addEnemy() {
let enemy = SKSpriteNode(color: UIColor.blue, size: CGSize(width: Self.enemySize, height: Self.enemySize))
let actualY = random(min: Self.enemySize / 2, max: size.height - Self.enemySize / 2)
enemy.position = CGPoint(x: size.width + Self.enemySize, y: actualY)
enemy.physicsBody = SKPhysicsBody(rectangleOf: CGSize(width: Self.enemySize, height: Self.enemySize))
guard let physicsBody = enemy.physicsBody else {
return
}
physicsBody.affectedByGravity = false
physicsBody.contactTestBitMask = physicsBody.collisionBitMask
enemy.name = "enemy"
addChild(enemy)
let actualDuration = random(min: CGFloat(2.0), max: CGFloat(4.0))
let actionMove = SKAction.move(to: CGPoint(x: -enemy.size.width/2, y: actualY),
duration: TimeInterval(actualDuration))
let actionMoveDone = SKAction.removeFromParent()
enemy.run(SKAction.sequence([actionMove, actionMoveDone]), completion: {
if self.data.lives > 0 {
self.data.lives -= 1
}
if self.data.lives <= 0 {
self.data.gameOver = true
self.scene?.view?.isPaused = self.data.gameOver
}
})
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touchLocation = touches.first?.location(in: self) else {
return
}
let projectile = SKShapeNode(circleOfRadius: Self.projectileSize)
projectile.fillColor = UIColor.green
projectile.position = playerPosition
projectile.zPosition = 1
projectile.physicsBody = SKPhysicsBody(rectangleOf: CGSize(width: Self.projectileSize, height: Self.projectileSize))
projectile.name = "projectile"
guard let physicsBody = projectile.physicsBody else {
return
}
physicsBody.contactTestBitMask = physicsBody.collisionBitMask
addChild(projectile)
physicsBody.applyImpulse(CGVector(dx: (touchLocation.x - playerPosition.x) / 50, dy: (touchLocation.y - playerPosition.y) / 50))
}
}

Swift로 작성된 SpriteKit 게임이므로 게임의 논리에 대해 너무 걱정하지 마십시오. SpriteKit에 대해 잘 모르는 경우에는 분명히 알지 못하지만 시작하는 데 도움이되는 많은 자습서가 있습니다.

알아야 할 중요한 점 SpriteView은 SwiftUI에 2D 게임을 삽입하는 쉬운 방법을 제공한다는 것입니다.

2.0의 새로운 기능 : TextEditor

WWDC 2020 이전에는 TextField또는 .NET을 사용하는 iOS에서만 텍스트 편집을 처리 할 수있었습니다 SecureTextField. 이들은 기본적으로 동일한 텍스트 필드이며 SecureTextField암호 필드와 마찬가지로 문자를 검은 색 원으로 대체하여 입력하는 내용을가립니다. 이러한 텍스트 필드의 중요한 유사점은 한 줄만 허용한다는 것입니다. 즉, 여러 줄 편집을위한 유일한 옵션 은 UIKit UIViewRepresentable에서 변환 UITextView하는 데 사용 하는 것입니다 .

struct ContentView : View {
@State private var text = ""
var body: some View {
MultilineTextView(text: $text)
}
}
struct MultilineTextView: UIViewRepresentable {
@Binding var text: String
func makeUIView(context: Context) -> UITextView {
let view = UITextView()
view.isScrollEnabled = true
view.isEditable = true
view.isUserInteractionEnabled = true
return view
}
func updateUIView(_ uiView: UITextView, context: Context) {
uiView.text = text
}
}

이것은 비교적 복잡하지만 더 많은 속성을 허용합니다. 입력 된 텍스트에서 탭할 수있는 URL을 만드는에 대한 UITextView변경을 허용 dataDetectorTypes합니다. 현재 텍스트는를 사용하여 새 텍스트로 바꿀 수 있으며 clearsOnInsertion를 호출하여 지정된 문자열이 표시 될 때까지 스크롤 할 수 있습니다 scrollRangeToVisible. 자세한 정보는 UITextView 문서를 확인하십시오 .

이러한 속성에 액세스 할 수는 없지만 새로운 속성 TextEditor TextField.

이제 TextEditor한 줄을 만드는 것처럼 쉽게 여러 줄을 만들 수 있습니다 TextField.

struct TextEditingView: View {
@State private var fullText: String = "This is some editable text..."
var body: some View {
TextEditor(text: $fullText)
}
}

에 적용 할 Text수있는 모든 항목을에 적용 할 수 있습니다 TextEditor. 사용자 지정 글꼴과 함께 새로운 동적 유형 구문 을 사용하려고했을 때 텍스트가 TextStyle. 이것은 첫 번째 베타의 버그이거나 내가 한 방식의 문제 일 수 있습니다. 내가 문서 편집기의 기능을 만들 수 어쨌든, 가장 좋은 예는과 글꼴 크기를 변경 할 수 있도록했다 Stepper과 함께 글꼴 무게를 Picker. A ColorPicker를 사용하여 전경 (글꼴) 색상을 선택할 수 있지만 배경이 현재 작동하지 않는 것 같습니다.

import SwiftUI
enum WeightType: String, CaseIterable {
case black, bold, heavy, light, medium, regular, semibold, thin, ultraLight
var weight: Font.Weight {
switch self {
case .black:
return .black
case .bold:
return .bold
case .heavy:
return .heavy
case .light:
return .light
case .medium:
return .medium
case .regular:
return .regular
case .semibold:
return .semibold
case .thin:
return .thin
case .ultraLight:
return .ultraLight
}
}
}
struct TextEditorView: View {
@State private var text: String = ""
@State private var fontWeight = WeightType.regular
@State private var fontSize = CGFloat(12)
@State private var customFont = true
@State private var foregroundColor = Color.primary
@State private var colourPickerShown = true
var body: some View {
ScrollView(.vertical) {
VStack {
Picker(selection: $fontWeight, label: EmptyView()) {
ForEach(WeightType.allCases, id: \.self) { fontWeight in
Text(fontWeight.rawValue)
}
}
.frame(height: 130)
.offset(y: -25)
Stepper(value: $fontSize, in: 1...100) {
Text("Font size: \(String(format: "%.1f", fontSize))")
}
HStack {
Button("Delete all") {
self.text = ""
}
.padding()
.background(Color.secondary)
.foregroundColor(.white)
.cornerRadius(5)
ColourPickerButton(dismissSheet: true, buttonTitle: "Text colour", previousColour: foregroundColor, colour: $foregroundColor, colourPickerShown: $colourPickerShown)
RoundedRectangle(cornerRadius: 5)
.aspectRatio(1, contentMode: .fit)
.foregroundColor(foregroundColor)
}
.frame(height: 50)
TextEditor(text: $text)
.font(.system(size: fontSize, weight: self.fontWeight.weight))
.foregroundColor(foregroundColor)
.frame(height: 300)
.padding()
.border(Color.secondary, width: 4)
}
.padding(.horizontal)
}
}
}
struct ColourPickerButton: View {
let dismissSheet: Bool
let buttonTitle: String
let previousColour: Color
@Binding var colour: Color
@State var colourInitialised = false
@Binding var colourPickerShown: Bool
var body: some View {
Button(buttonTitle) {
colourPickerShown.toggle()
}
.padding()
.background(Color.secondary)
.foregroundColor(.white)
.cornerRadius(5)
.onChange(of: colour) { _ in
if dismissSheet && colour == previousColour {
self.colourPickerShown.toggle()
}
}
.sheet(isPresented: $colourPickerShown) {
ColourPickerSheetView(colour: $colour, previousColour: colour)
}
}
}
struct ColourPickerSheetView: View {
@Binding var colour: Color
let previousColour: Color
var body: some View {
VStack {
if colour == previousColour {
Text("Swipe down to dismiss")
ColorPicker("Colour", selection: $colour)
}
}
}
}

TextEditorsystemBackground 색상 의 불투명 한 배경이있는 것처럼 보이며 배경을 추가하면 배경이 뒤에 배치됩니다.

배경이 전혀 보이지 않습니다.

해결 방법이 있는데, 아마도 지금은 우리가 할 수있는 전부일 것입니다 :

TextEditor는 아래 스크린 샷과 같이 테두리를 지원합니다.

2.0의 새로운 기능 : SignInWithAppleButton

Apple로 로그인은 이메일과 암호를 제공하지 않고도 앱에 안전하게 로그인하는 방법으로 iOS 13에 도입되었습니다. Apple로 로그인은 생체 인식을 사용하여 본인이 Apple ID를 소유 한 사람임을 인증 한 다음 앱에 자동으로 생성 된 전달 주소와 암호를 앱에 보냅니다.

이 새로운 버튼은 앱에서 Apple로 로그인을 채택하는 프로세스를 간소화합니다.

다음 예제는 SignInWthAppleButton허용되는 최대 크기 (너비 ≤ 375)에서 를 표시합니다 . 이보기에는 제약이 있습니다. Apple은 방금 기존 ASAuthorizationAppleIDButton 을으로 래핑 한 것 같습니다 UIViewRepresentable. 이는 작년에 SwiftUI에 버튼을 가져 오기 위해 필요한 작업입니다. 그러나 iOS 13에서 Apple로 로그인  구현하는 데 필요한 단계를 확인 하면 코디네이터에서 위임 프로토콜을 설정하는 추가 작업 이 필요하지 않습니다 .

import SwiftUI
import AuthenticationServices
struct SignInWithAppleView: View {
@State var output = ""
func output(_ result: Result<ASAuthorization, Error>) {
switch result {
case .success (let authResults):
self.output = "Authorization successful\n\n\(authResults)\n\(authResults.provider)\n\(authResults.credential)"
case .failure (let error):
self.output = "Authorization failed: \(error.localizedDescription)"
}
print(self.output)
}
var body: some View {
VStack {
Text("Tap the button to authenticate.\nYou must be signed into an Apple ID on this device.")
.foregroundColor(.black)
.lineLimit(nil)
SignInWithAppleButton(
.signIn,
onRequest: { request in
request.requestedScopes = [.fullName, .email]
},
onCompletion: { result in
output(result)
})
.frame(maxWidth: 375)
.aspectRatio(7, contentMode: .fit)
Text(output)
.foregroundColor(.black)
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.white)
}
}

Apple의 예에서 채택한 내 버전은 인증 결과를 Text앱에 출력하고 인쇄합니다. 처음 시도 할 때 로그인 버튼이 아무 작업도하지 않는다는 것을 알 수 있습니다. 그것은 확실히 제 경험이었습니다.

두 번 누르면 예상대로 출력이 표시됩니다.

ProgressView (2.0의 새로운 기능)

UIActivityIndicatorView 는 미정 인로드 상태에 대한 스피너를 표시 할 수있는 UIKit 컨트롤입니다. UIViewRepresentable로 래핑하지 않고 직접 사용할 수 없었지만 이제는 이에 상응하는 것이 있습니다! 매개 변수없이 ProgressView를 생성하면 스피너가 표시되지만 진행률 값을 전달하면 수평 진행률 표시 줄로 표시 될 수 있습니다.

진행률 표시 줄 양식은 웹 페이지가로드 될 때 와 함께 자주 사용되는 UIProgressView 와 유사합니다 WKWebView.

struct ProgressingView: View {
@State private var progress = 0.5
var body: some View {
VStack(spacing: 15) {
ProgressView()
ProgressView("Downloading", value: progress, total: 1)
.progressViewStyle(CircularProgressViewStyle(tint: .accentColor))
ProgressView(value: progress)
.accentColor(.red)
Slider(value: $progress, in: 0...1)
}
}
}

중간 예제에 이름, 값 및 총 매개 변수가 있음을 알 수 있습니다. 일반적으로 이렇게 ProgressView하면 하단 예제와 같이 가로 진행률 표시 줄로 표시됩니다. 그러나 .progressViewStyle수정자를 적용 CircularProgressViewStyle하여 기본값과 일치하는 색조로에 전달 했음을 알 수 accentColor있습니다. 진행률 표시 줄이 아닌 결과는 파란색 원형 스피너입니다.

스타일이 적용될 때 "다운로드 중"레이블은 유지 ProgressView되지만는 원형 이 되어야하며 이로 인해 자동 동작이 취소 된다는 사실을 보여주는 그림입니다 .

GaugeView (2.0의 새로운 기능)

GaugeViewViewWatchOS 7 전용 의 유일한 새로운 기능 입니다. 이것은 값이 척도에있는 위치를 표시하는 비교적 간단한 지표입니다. 아래의 예에서는 이 표시 Slider되는 값을 변경 하기 위해를 추가했습니다 Gauge. Slider는 실제로 현재 값이 자체 파란색 막대에있는 위치를 표시하므로 약간 이상하게 보입니다. 게이지 스타일링은 매우 어렵습니다. 또한 호출되는 기본 게이지 스타일은 LinearGaugeStyle사용 Color.primary의 전경 색상으로, 그리고 사용 .foregroundColor또는 .accentColor수정하면이 변경되지 않습니다. 마찬가지로 CircularGaugeStyle사용 Color.gray, 그리고 이것은 변경할 수 없습니다.

import SwiftUI
struct GaugeView: View {
let sliderValue: Double
var body: some View {
VStack {
Gauge(value: sliderValue, in: 0...1) {
Text("Gauge")
}
.frame(maxHeight: .infinity)
}
}
}
struct ContentView: View {
@State var isCircular = false
@State var sliderValue = Double()
var body: some View {
VStack {
Toggle(isOn: $isCircular) {
Text("Circular")
}
if isCircular {
GaugeView(sliderValue: sliderValue)
.gaugeStyle(CircularGaugeStyle())
}
else {
GaugeView(sliderValue: sliderValue)
.gaugeStyle(LinearGaugeStyle())
}
Text("\(sliderValue)")
Slider(value: $sliderValue, in: 0...1) {
Text("Slider")
}
}
.padding()
}
}
view rawGaugeView.swift hosted with ❤ by GitHub

게이지에서 현재 값이있는 위치를 나타내는 엄지 손가락 또는 원은 적어도의 경우 마스크처럼 보입니다 LinearGaugeStyle. 추가 .background(Color.blue)하면 원의 색상이 변경됩니다.

라벨 (2.0의 새로운 기능)

struct LabelView: View {
var body: some View {
VStack {
Label("Games", systemImage: "gamecontroller")
HStack {//Roughly equivalent to
Image(systemName: "gamecontroller")
Text("Games")
}
}
}
}
view rawLabelView.swift hosted with ❤ by GitHub

이것은 Apple의 현재 더 큰 SF Symbols 무료 아이콘 컬렉션의 심볼을 더 큰 컨텍스트를 제공하는 텍스트와 결합하는 비교적 간단한 방법입니다. 내 예제에서 나는 비교하고 Label를 사용하여 해당과 함께 HStack. 거기의 정렬 의한 최초의 엑스 코드 (12) 베타 버그했습니다 Label Image텍스트로 잘못 정렬 할 수는 있지만, 지금은 수정되었습니다. 에 대한 두 가지 옵션이 있습니다. 그 .labelStyle중 하나 Text Image.

링크 (2.0의 새로운 기능)

저는 항상 iOS 앱에서 하이퍼 링크를 사용할 수 없다는 것이 조금 아쉽다고 생각했습니다. 물론 URL을 여는 파란색 텍스트가있는 버튼을 만들 수 있지만 이렇게하려면 매번 작성하려는 것보다 더 많은 코드가 필요합니다. 이 예제에서는 Button원래 형식으로 코드를 얻을 수있는만큼 작기 때문에 제목 문자열 (또는 지역화 된 문자열 키) 만 받는 편리한 이니셜 라이저를 사용하고 있습니다.

struct LinkStyleView: View {
let urlString = "https://medium.com/better-programming/the-complete-swiftui-documentation-youve-been-waiting-for-fdfe7241add9"
var body: some View {
Group {
if URL(string: urlString) != nil {
//The old way to create a Link-style Button
Button("Read more") {
if let url = URL(string: urlString) {
UIApplication.shared.open(url, options: [:], completionHandler: {_ in })
}
}
}
else {
EmptyView()
.onAppear { assertionFailure("URL was nil") }
}
}
}
}
view rawLinkStyleView.swift hosted with ❤ by GitHub

iOS 14에는 이제 위 Link의 작업 부분을 수행하는이 Button있습니다. 나는 Apple의 문서 가 '!'를 사용하여 URL을 안전하지 않게 언 래핑하는 방식이 마음에 들지 않았습니다 . 왜냐하면 이것은 코드 샘플에서 권장하는 매우 나쁜 습관이기 때문입니다. 물론, URL을 IP 주소로 변환하는 IANA (Internet Assigned Numbers Authority) 소유 사이트 인 example.com/TOS.html에 연결되기 때문에이 특정 URL이 성공적으로 생성되었음을 알 수 있습니다 .

그러나 URL 문자열이 유효하다는 인간의 확신에 맡기면 조만간 실수하게 될 것입니다.

선택 사항을 안전하지 않게 풀 때 예기치 않게 nil을 찾는 앱은 즉시 충돌합니다.

그렇기 때문에 위의 예제는 Apple의 예제보다 몇 줄 더 걸리지 만 안전하게 수행합니다. 이 예제는 SwiftUI의 첫 번째 버전에서 선택적 바인딩 ( if let또는 guard let) 이 부족하여 실제로 방해가됩니다. 대신 URL이 존재하는지 확인하기 위해 URL을 nil과 비교하는 것으로 제한되어 있습니다. 이 비교에서 URL이 nil이 아님을 확인하더라도, 이것이 Button먼저 래핑을 풀지 않고 에서 사용할 수 있다는 의미는 아닙니다 . 이것이 Button액션 에 약간 혼란스러운 추가 단계가있는 이유 입니다. 선택적으로 URL을 바인딩하여 nil이 아닌지 확인합니다.

in the action assertionFailure뒤에 else 문 을 넣을 수 있었지만 예제 와의 일관성을 위해 를 추가하고 싶었습니다 . 포함하는 else 문 은 필요하지 않습니다. 클로저 의 유일한 점유자 주변의 모든 if 문 은 if 조건이 거짓 일 때 반환 됩니다. 그러나 URL이 nil이면 어떤 일이 발생할지 보여주기 위해 이것을 명시 적으로 추가하고 싶었습니다. 사용자는 아무것도 볼 수 없지만 디버그 모드에서 개발자에 대해 어설 션이 트리거됩니다.if letButtonEmptyViewLinkEmptyViewViewBuilderEmptyView

이렇게하면 URL이 nil임을 알 수 있지만 최종 사용자에게 충돌을 일으키지 않습니다.

struct LinkView: View {
let urlString = "https://medium.com/better-programming/the-complete-swiftui-documentation-youve-been-waiting-for-fdfe7241add9"
var body: some View {
Group {
if let url = URL(string: urlString) {
//The new way to create a Link
Link("Read more", destination: url)
}
else {
EmptyView()
.onAppear { assertionFailure("URL was nil") }
}
}
}
}
view rawLinkView.swift hosted with ❤ by GitHub

이제 SwiftUI가 지원하는 경우 URL과 같은 속성을 직접 만들고 해당 데이터를 사용하는 뷰를 만들 수 있습니다. 이전과 마찬가지로 링크는 URL을 생성 할 수있을 때만 표시되지만이 경우를 확인하기 위해 여러 번 확인할 필요는 없습니다.

메뉴 (2.0의 새로운 기능)

기본 스타일은 BorderedButtonMenuStyle입니다.

MenuButton이 (가)로 대체되었습니다 Menu. 원본은 드롭 다운 메뉴 였고 대체품도 그리 다르지 않습니다. 새 이름이 추가하는 가장 중요한 것은 메뉴와 그 안의 항목에 대해 이야기하고 있으므로 명확성이므로 버튼이라고 부르는 것은별로 의미가 없습니다. Menu스타일에 대한 몇 가지 다른 옵션이 제공됩니다. 기본 스타일은 BorderedButtonMenuStyle입니다.이 때문에 DefaultButtonMenuStyle오른쪽은 똑같이 BorderlessButtonMenuStyle보이고 가운데는 다르게 보입니다.

import SwiftUI
@available(OSX 10.16, *)
@available(iOS, unavailable)
@available(tvOS, unavailable)
@available(watchOS, unavailable)
struct MenuView : View {
@State var selectedOption = "Select an option"
var body : some View {
HStack {
Menu(selectedOption) {
Button(action: {self.selectedOption = "Option 1"}) {
Text("Option 1")
}
Button(action: {self.selectedOption = "Option 2"}) {
Text("Option 2")
}
Button(action: {self.selectedOption = "Option 3"}) {
Text("Option 3")
}
}
.menuStyle(BorderedButtonMenuStyle())
Menu(selectedOption) {
Button(action: {self.selectedOption = "Option 1"}) {
Text("Option 1")
}
Button(action: {self.selectedOption = "Option 2"}) {
Text("Option 2")
}
Button(action: {self.selectedOption = "Option 3"}) {
Text("Option 3")
}
}
.menuStyle(BorderlessButtonMenuStyle())
Menu(selectedOption) {
Button(action: {self.selectedOption = "Option 1"}) {
Text("Option 1")
}
Button(action: {self.selectedOption = "Option 2"}) {
Text("Option 2")
}
Button(action: {self.selectedOption = "Option 3"}) {
Text("Option 3")
}
}
.menuStyle(DefaultMenuStyle())
}
.padding()
.frame(height: 50)
}
}
view rawRevDoc Menu.swift hosted with ❤ by GitHub

그외 메뉴에 "풀다운는"사실에 대한 참조를 제거하고 새로운 스타일에서 유일한 변화는이 MenuButton라고 Menu하고, .menuButtonStyle수정이 지금이라고합니다 .menuStyle.

첫 번째 베타에서는 Menu대체하는 컨트롤과 마찬가지로 macOS에서만 사용할 수있었습니다.

그러나 베타 3는 iOS에서도 메뉴 지원을 추가했습니다 .

MenuButton (2.0에서 더 이상 사용되지 않음)

Menu를 대체 MenuButton하지만 동일한 기능을 많이 제공 하는 위를 참조하십시오 .

텍스트 (2.0에서 업데이트 됨)

텍스트는 뷰를 만드는 가장 간단한 구성 요소 일 것입니다. 대부분의 경우를 전달 String하여 생성하고 이것이 표시되는 콘텐츠가됩니다. 이 문서의 원래 버전에는 지역화, ObservableObjects 및 하위 문자열을 포함하여 문서를 만드는 다른 모든 방법이 포함되어 있습니다. 이 예제의 맨 아래에서 찾을 수 있지만 2020 년에는 예제 맨 위에 포함 된 많은 새로운 이니셜 라이저가 있습니다.

첫 번째는 .NET Framework에서 상속하는 모든 일반 객체와 모든 클래스를 사용할 수 있으므로 매우 흥미 롭습니다 Formatter. 이것은 FormatterApple이 제공 하는 유형이거나 내 예에서와 같이 Formatter사용자 정의 유형을 위해 특별히 만든 유형일 수 있습니다. a Formatter가 구현 되는 방법을 정확히 알지 못했기 때문에 선택적으로 개체를 사용자 지정 클래스의 인스턴스에 바인딩하고 다른 모든 상황에서는 nil을 반환했습니다.

주의점 걸리는 일이 LocalizedStringKey, tableName, bundle, 및 comment용도 별도의 파일이 필요합니다 .strings파일 확장자를. 이 initializer에 대한 Apple의 문서 에서 언급했듯이 유일한 필수 매개 변수는 키의 문자열입니다. 이러한 다른 매개 변수가 무엇을 필요로하는지 알 수 있도록 대부분의 자세한 예제를 제공했습니다.

의 기본 tableNameIS Localizable하는 문자열 파일의 표준 이름. Local이 매개 변수가 필요한 이유를 보여주기 위해 일부러 내 이름을 지정했습니다 . 번들은 기본적으로 기본 번들이므로이 Bundle.main경우 전달 은 중복됩니다. 주석은 상황에 맞는 정보를 제공해야하지만이 예제에서는 문자열 Comment.

/*
Separate file called Localizable.strings
"string_key" = "This string is in the default file";
Separate file called Local.strings
"string_key" = "This string is in another file";
*/
import SwiftUI
final class DataModel: ObservableObject {
static let shared = DataModel()
@Published var string = "This is an ObservedObject string"
}
class MyFormatter: Formatter {
override func string(for obj: Any?) -> String? {
if let customObj = obj as? CustomType {
return customObj.name + "\n" + customObj.text
}
else {
return nil
}
}
}
class CustomType: NSObject {
let name: String
let text: String
init(name: String, text: String) {
self.name = name
self.text = text
}
}
struct ContentView: View {
@ObservedObject var data = DataModel.shared
let substring: Substring = "This is a substring"
let string = "This is a string"
let codeUpdated = Date(timeIntervalSince1970: 1594548848)
var body: some View {
VStack {
Group {
//NEW: Use a custom type and a custom formatter to display it
Text(customObject, formatter: formatter)
//NEW: Add an Image with string interpolation
Text("\(Image(systemName: "gamecontroller")) Games")
//NEW: Add an Image with string interpolation
Text(Image(systemName: "gamecontroller"))
//NEW: A range between two dates
Text(ClosedRange<Date>(uncheckedBounds: (lower: codeUpdated, upper: Date())))
//NEW: A range between a date and an added duration (1 day in seconds)
Text(DateInterval(start: codeUpdated, duration: 86400))
//NEW: Use the new DateStyle to specify formatting as a duration, not a date range
Text(codeUpdated, style: .relative)
//NEW: Change the case of text
Text("Make this uppercase")
.textCase(.uppercase)
Text("MAKE THIS LOWERCASE")
.textCase(.lowercase)
Text("KEEP THIS THE SAME")
.textCase(.none)
}
Group {
//NEW: Font styles
Text("Caption 2")
.font(.caption2)
Text("Title 2")
.font(.title2)
Text("Title 3")
.font(.title3)
//NEW: Font designs
Text("Monospaced")
.font(.system(.body, design: .monospaced))
Text("Serif")
.font(.system(.body, design: .serif))
}
Group {
//This is a substring
Text(substring)
//This is a string
Text(string)
//This is an ObservedObject string
Text(data.string)
//This uses the text 'string_key' exactly as it is without using it to look up a localisation
Text(verbatim: "string_key")
//This string in the default file
Text("string_key")
//This string is in another file
Text("string_key", tableName: "Local", bundle: Bundle.main, comment: "Comment")
}
}
}
}
view rawRevDoc Text.swift hosted with ❤ by GitHub

Text이제 Imageusing 보간을 포함 하거나 Date. 세 가지 새로운 글꼴 : caption2, title2 title3. 이들은 UIFont의 일부로 사용할 수 있었으므로 SwiftUI로 만든 것은 놀라운 일이 아닙니다. 새로운 수정자를 사용하면 텍스트가 대문자인지 소문자인지와 고정 폭 또는 세리프 디자인을 사용할지 여부를 선택할 수 있습니다.

Dynamic Type을 사용하여 사용자 정의 글꼴을 시스템 스타일만큼 액세스 할 수 있도록 만드는 새로운 방법은 포함하지 않았습니다. 이를 위해 Hacking With Swift의 Dynamic Type 가이드에 링크 할 것입니다. 거기에서 말한 내용 만 반복 할 것입니다.

이미지 (2.0에서 업데이트 됨)

SF Symbols 2는 Mac 앱에서의 사용을 지원합니다. Image(systemNamed:)Xcode 11에서 SF 기호를 사용 하려고 하면 "외부 인수 레이블 'systemNamed :'in call"오류가 발생합니다. 즉, macOS에는 표시 할 방법이 없기 때문에 기본 Mac 앱 또는 Catalyst 앱에서도 SF Symbols를 사용할 수 없습니다. 아마도 macOS Catalina에서 실행되는 SF Symbols Mac 앱은 .NET을 사용할 수 없었기 때문에 심볼의 썸네일로 PNG를 사용했을 것 Image(systemNamed:)입니다.

어쨌든 Xcode 12 및 macOS 11 Big Sur부터는 이러한 경고가 표시되지 않으며 Image(systemNamed:)기본 macOS 및 Mac Catalyst 앱에서 사용할 수 있습니다 .

버튼 (2.0에서 업데이트 됨)

이제 CardButtonStyletvOS에는 색상이 덜 눈에 띄는 작은 버튼을 만드는 옵션 이 있습니다 . 위의 예는 Button스타일이 전혀없이 생성 된를 보여 DefaultButtonStyle주므로를 사용하므로 차이를 확인할 수 있습니다. 새로운 Button것은 훨씬 더 미묘하며 더 밝은 색상이 주어진 옵션보다 덜 중요한 옵션에 사용할 수 있습니다.

import SwiftUI
struct ContentView: View {
var body: some View {
VStack {
Button("DefaultButtonStyle") {
print("Default button pressed")
}
Button(action: { print("CardButtonStyle pressed")}) {
Text("CardButtonStyle")
.padding()
}
.buttonStyle(CardButtonStyle())
}
}
}

CardButtonStyle Button어떤 이유로 패딩이 없기 때문에 에 패딩을 추가해야했습니다 . 이것은 Xcode 12 베타 2에서만 테스트하고 있으므로 이후 베타에서 변경 될 수 있습니다.

PasteButton (2.0에서 업데이트 됨)

이 컨트롤을 사용하면 MacOS에 정보를 붙여 넣을 수 있지만 iOS에서는 사용할 수 없습니다. UTI로 표현되는 다양한 데이터 유형을 사용할 수 있습니다. Apple의 문서를 인용하기 위해 "Uniform Type Identifiers는 다른 앱에서로드, 저장 또는 열 수있는 리소스에 대한 공통 유형을 선언합니다." Xcode 11에서는 .NET Framework를 만들 때 이러한 문자열을 배열에 제공해야했습니다 PasteButton.

이 버튼을 구현할 때 도움이 될 모든 유형의 UTI 문자열을 찾을 수있는 함수를 예제에 포함했습니다.

이제 UTType지원하려는 유형을 훨씬 쉽게 만들 수있는 라는 새로운 구조 가 있습니다. 이 구조의 이니셜 라이저에 Xcode 11에서 작동했던 문자열을 전달하거나 제공된 많은 시스템 선언 유형 중 하나를 사용할 수 있습니다 .

import SwiftUI
import UniformTypeIdentifiers
@available(OSX 10.16, *)
@available(iOS, unavailable)
@available(tvOS, unavailable)
@available(watchOS, unavailable)
struct PasteButtonView: View {
@State var text = String()
let utType = UTType.text
//The declaration above is equivalent to
//let utType = UTType("public.utf8-plain-text")!
var body: some View {
VStack {
Text(text)
PasteButton(supportedContentTypes: [utType], payloadAction: { array in
guard let firstItem = array.first else {
return
}
firstItem.loadDataRepresentation(forTypeIdentifier: "public.utf8-plain-text", completionHandler: {
(data, error) in
guard let data = data else {
return
}
let loadedText = String(decoding: data, as: UTF8.self)
self.text = loadedText
//This call just shows how to find print the UTI type of any type conforming to NSItemProviderWriting, "public.utf8-plain-text" in this case
self.getUTITypeString(for: loadedText)
})
})
}
.frame(width: 200, height: 200)
}
func getUTITypeString(for item: Any) {
if let item = item as? NSItemProviderWriting {
let provider = NSItemProvider(object: item)
print(provider)
}
else {
print("This data type cannot be used in an NSItemProvider")
}
}
}

문자열을받는 이니셜 라이저는 선택 사항을 반환하므로 사용하는 경우 사용하는 문자열이 올바른지 확인해야합니다. 내가 사용하고 UTType.text내 예를 들어,하지만 문자열에서 수동으로 구성 아래 나는 예를 포함. 느낌표를 사용하여 옵션을 강제로 풀지 않는 것이 좋습니다. UTType시스템 선언 유형에서 얻은 유형이 nil이 아니므로 동일한 유형이 문자열에서 생성되지 않음 을 보여주고 싶었습니다 .

필요한 유형 식별자를 결정했으면에서 가져온 데이터를 처리해야합니다 NSItemProvider. 내 예제는 배열의 첫 번째 항목 만 붙여 넣지 만 다른 데이터 유형과 여러 항목을 처리 할 수있는 방법을 명확하게 보여줍니다.

Here’s a list of the types that conform to NSItemProviderWriting, and can therefore be used for pasting with the PasteButton:

Toggle (Updated in 2.0)

The default style on iOS, SwitchToggleStyle, now allows us to choose a tint colour that is only shown when the bool the Toggle has a Binding to is true. While the default Toggle tint on iOS and iPadOS is green, on Mac Catalyst it is blue. Using the new SwitchToggleStyle with the tint colour option in a native Mac app currently displays a switch as we would expect, but the tint colour is still the default blue.

import SwiftUI
struct ToggleTintView: View {
@State var toggleIsOn = true
var body: some View {
VStack {
Toggle(isOn: $toggleIsOn) {
Text("Toggle")
}
Toggle(isOn: $toggleIsOn) {
Text("Toggle")
}
.toggleStyle(SwitchToggleStyle(tint: .red))
}
.padding()
}
}

The top Toggle, which I added without a ToggleStyle for comparison, is displayed in the macOS default of CheckboxToggleStyle. This is a checkbox that displays a checkmark when on and does not have tint colour options. I’ve added an accentColor modifier to this Toggle, which shows that macOS actually does allow the Toggle tint to be changed when the user has not selected ‘Accent Color’ in the Highlight Colour dropdown menu in the General section of their System Preferences.

This can be used to change either style of Toggle; I just didn’t add the accentColor modifier to the second one to show how the SwitchToggleStyle with tint colour has no effect.

DatePicker (Updated in 2.0)

There are now two new styles for DatePicker, called GraphicalDatePickerStyle and CompactDatePickerStyle.

You might notice that the version of GraphicalDatePickerStyle on macOS on the right is much smaller than the one that is shared by iOS, iPadOS, and Mac Catalyst on the left. I could only get the DatePicker to display correctly when I allowed it a minimum height of 400. Any less and the DatePicker clips some of the text — in particular, the part that is used to set a time.

This causes the width to scale proportionally, meaning that on a small device like iPhone 8, the DatePickerexceeds the screen width. Perhaps this will be fixed in a future beta, as I was using Xcode 12 beta 2. Unfortunately, it seems that the WheelDatePickerStyle, which is available on iOS, iPadOS and Mac Catalyst but not a native macOS app, still continues to have a minimum width that leaves very little space for its label on small screens.

import SwiftUI
struct ContentView: View {
@State var date = Date()
var body: some View {
ScrollView(.vertical) {
DatePicker("No style", selection: $date)
DatePicker("DefaultDatePickerStyle", selection: $date)
.datePickerStyle(DefaultDatePickerStyle())
DatePicker("GraphicalDatePickerStyle", selection: $date)
.datePickerStyle(GraphicalDatePickerStyle())
.frame(height: 400)
DatePicker("CompactDatePickerStyle", selection: $date)
.datePickerStyle(CompactDatePickerStyle())
#if os(iOS) || targetEnvironment(macCatalyst)
DatePicker("WheelDatePickerStyle", selection: $date)
.datePickerStyle(WheelDatePickerStyle())
#else
DatePicker("FieldDatePickerStyle", selection: $date)
.datePickerStyle(FieldDatePickerStyle())
DatePicker("StepperFieldDatePickerStyle", selection: $date)
.datePickerStyle(StepperFieldDatePickerStyle())
#endif
}
.padding()
}
}

CompactDatePickerStyle is now the default on iOS, iPadOS and Mac Catalyst. This is essentially a button that displays the current value, and displays a tiny calendar similar to the GraphicalDatePickerStyle when the button is tapped. This allows you to have a very compact display for the current date, without needing to display an entire calendar at all times.

My example has the conditional compilation flag #if os(iOS) || targetEnvironment(macCatalyst) around the WheelDatePickerStyle example.

This means that no matter what platform you build it for, you will see all of the DatePicker variations that are supported on that platform.

New and Updated View Modifiers in 2.0

.matchedGeometryEffect (NEW in 2.0)

As you might be able to tell from the name, .matchedGeometryEffectt is an animation effect that animates changes in size and position.

Like Sarun’s example, I used a Text and a Shape, but mine is a Circle instead of a RoundedRectangle (totallydifferent). Instead of a VStack that turns into an HStack on the basis of a bool, I thought it would be interesting to position two views in a variety of configurations. These configurations are relative to the TextPosition enum, and you can guess what the value of that means. The values are left, centre, right, top and bottom. When the Text is in the centre, the Circle adjusts for this by reducing its opacity, making it easier to see the text that is layered on top of it in a ZStack.

import SwiftUI
struct MatchedTextView: View {
let namespace: Namespace.ID
let colour: Color
var body: some View {
Text("Tap to move!")
.fontWeight(.semibold)
.foregroundColor(colour)
.matchedGeometryEffect(id: "text", in: namespace)
}
}
struct MatchedRoundedView: View {
let namespace: Namespace.ID
let colour: Color
let isCentre: Bool
var body: some View {
Circle()
.foregroundColor(colour)
.opacity(isCentre ? 0.5 : 1)
.frame(width: 60, height: 60)
.matchedGeometryEffect(id: "rect", in: namespace)
}
}
struct MatchedGeometryEffectView: View {
@State private var position: TextPosition = .bottom
@Namespace private var namespace
var body: some View {
Group {
switch position {
case .bottom:
VStack {
MatchedRoundedView(namespace: namespace, colour: position.colour, isCentre: position == .centre)
MatchedTextView(namespace: namespace, colour: position.colour)
}
case .left:
HStack {
MatchedTextView(namespace: namespace, colour: position.colour)
MatchedRoundedView(namespace: namespace, colour: position.colour, isCentre: position == .centre)
}
case .centre:
ZStack {
MatchedRoundedView(namespace: namespace, colour: position.colour, isCentre: position == .centre)
MatchedTextView(namespace: namespace, colour: position.colour)
}
case .right:
HStack {
MatchedRoundedView(namespace: namespace, colour: position.colour, isCentre: position == .centre)
MatchedTextView(namespace: namespace, colour: position.colour)
}
case .top:
VStack {
MatchedTextView(namespace: namespace, colour: position.colour)
MatchedRoundedView(namespace: namespace, colour: position.colour, isCentre: position == .centre)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.primary.colorInvert())
.onTapGesture {
withAnimation {
position.next()
}
}
}
}
enum TextPosition: CaseIterable {
case bottom, left, centre, right, top
var colour: Color {
switch self {
case .bottom:
return .orange
case .left:
return .red
case .centre:
return .green
case .right:
return .blue
case .top:
return .purple
}
}
mutating func next() {
var index = 0
while TextPosition.allCases[index] != self { index += 1 }
if TextPosition.allCases.indices.contains(index + 1) {
self = TextPosition.allCases[index + 1]
}
else {
self = TextPosition.allCases[0]
}
}
}

My example is relatively basic, so check out these better sources:

The accessibility modifier .help provides tooltips in MacOS and an accessibility hint that works on both MacOS and iOS. My example shows how this new modifier actually overrides any accessibility hint that was previously applied and vice versa. When I ran this on iOS, VoiceOver read the first text as “Label 1, Help 1” because the help modifier was added after the hint. The second Text is read as “Label 2, Hint 2” because the hint was added after the help modifier.

import SwiftUI
struct ContentView: View {
var body: some View {
List {
//VoiceOver: Label 1, Help 1
Text("Help 1 overrides Hint 1")
.accessibility(hint: Text("Hint 1"))
.help(Text("Help 1"))
.accessibility(label: Text("Label 1"))
//VoiceOver: Label 2, Hint 2
Text("Hint 2 overrides Help 2")
.help(Text("Help 2"))
.accessibility(hint: Text("Hint 2"))
.accessibility(label: Text("Label 2"))
}
.frame(minWidth: 200, minHeight: 200)
}
}
view rawRevDoc help.swift hosted with ❤ by GitHub

This behaviour is similar on MacOS, except that a tooltip displaying the help text is displayed, regardless of whether help is overridden by an accessibility hint. In other words, although the second Text is still read by VoiceOver as “Label 2, Hint 2”, hovering over it with the mouse still displays “Help 2”.

In Mac Catalyst, it seems that neither the help modifier or the accessibility hint are read.

The tooltip functionality of help also seems absent from Catalyst.

.accessibility(inputLabels:) (NEW in 2.0)

Input labels are used by Voice Control (NOT VoiceOver) and Full Keyboard Access. When Voice Control is enabled, speaking a command such as ‘tap Input’ would press this Button. This gives you an array of different labels for your UI elements that are not visible to VoiceOver.

In other words, they are ways that someone can describe the Button verbally.

import SwiftUI
struct InputLabelsView : View {
var body: some View {
Button("My Button") {
print("My Button pressed")
}
.accessibility(label: Text("Label"))
.accessibility(inputLabels: [Text("Input"), Text("Labels")])
}
}

.accessibility(selectionIdentifier:) (Deprecated in 2.0)

This identifier was previously used by Picker to identify the current selection.

.scaleEffect (Updated in 2.0)

This isn’t new, but one of the original modifiers has been fixed. In Xcode 11, .scaleEffect(x: 2) causes Y to be scaled to zero. In Xcode 12, the default parameters are both 1, which means you can keep one the same and scale one of them without setting both.

Calling .scaleEffect() in Xcode 11 scaled the entire View to zero!

import SwiftUI
struct ContentView: View {
var body: some View {
VStack {
Rectangle()
.frame(width: 50, height: 50)
.scaleEffect()
Rectangle()
.frame(width: 50, height: 50)
.scaleEffect(x: 2)
}
.frame(width: 350, height: 600)
}
}

Try the example above in each version of Xcode and you’ll see what I mean.

In Xcode 11, both rectangles are invisible, whereas in Xcode 12 they appear as a square and a rectangle.

.imageScale (NEW in 2.0)

imageScale seems to only apply to symbols. I have tried passing the name of an image file into the initializer for Image, but the imageScale seems to have no effect.

import SwiftUI
struct Symbols: View {
var body: some View {
HStack {
Image(systemName: "square")
.imageScale(.small)
Image(systemName: "circle")
.imageScale(.medium)
Image(systemName: "triangle")
.imageScale(.large)
}
.frame(width: 100)
}
}
struct Labels: View {
var body: some View {
VStack {
Label("Games", systemImage: "gamecontroller")
.imageScale(.small)
Label("Games", systemImage: "gamecontroller")
.imageScale(.medium)
Label("Games", systemImage: "gamecontroller")
.imageScale(.large)
}
.frame(height: 100)
}
}

It will, however, work on Label, which also has an initializer that takes a system name for one of the provided SF Symbols.

.accentColor (Updated in 2.0)

The first example below was possible on iOS 13, macOS Catalyst 13, tvOS 13, and watchOS 6. The only platform it wasn’t supported on was macOS, but that has changed in 2020. With a Button this changes the font colour, which wasn’t possible before on that platform.

import SwiftUI
struct ContentView: View {
var body: some View {
VStack {
//Only possible on Mac in Xcode 12
Button("Button") {
print("Button pressed")
}
.accentColor(.red)
//Possible on Mac in Xcode 11
Button(action: {
print("Button pressed")
}) {
Text("Button")
.foregroundColor(.red)
}
}
}
}

In my second example, I’ve used foregroundColor to change the font colour of a Button on Mac. This works, but it is no longer necessary in Xcode 12.

Now we can use accentColor to change the font colour on all platforms, which allows me to use the version of Button that takes a string and doesn’t require me to create a Text.

.preferredColorScheme (Updated in 2.0)

The existing colorScheme(_ colorScheme: ColorScheme) modifier is now deprecated in the current versions of Apple operating systems. The purpose of this modifier was to override the system colour scheme for a single View and its subviews, while preferredColorScheme was used to override the colour scheme for the entire presentation. This refers to the popover or window that the View is being presented in.

import SwiftUI
struct ContentView: View {
@State var showingSheet = false
@State var sheetIsDark = true
var body: some View {
VStack {
Toggle(isOn: $sheetIsDark) {
Text("Sheet is dark")
}
Button("Present sheet") {
showingSheet = true
}
.preferredColorScheme(.light)
.sheet(isPresented: $showingSheet) {
Button("Dismiss") {
showingSheet = false
}
.preferredColorScheme(sheetIsDark ? .dark : nil)
}
}
.padding()
.frame(width: 300, height: 500)
}
}

While this modifier is not new, the new addition in 2020 is the fact that the parameter is now optional. To illustrate this, my example contains a Toggle that changes whether the sheet should be dark or not. With colorScheme, it was impossible to have the ternary expression sheetIsDark ? .dark : nil, but now we can with preferredColorScheme.

It might seem like a disadvantage that preferredColorScheme only applies to the presentation, not each individual View, but I’m not sure why you’d want to apply a colour scheme to only one View.

You either want a screen of your app to use the system colour scheme, or you want to override it with your own.

If you do want individual Views to act as if the colour scheme is different, you can just change the colours they would be in either scheme.

.textContentType (NEW in 2.0)

This modifier tells the device what kind of suggestions would be helpful when a user is typing.

You might notice that this modifier takes a different type on Mac compared to the other platforms. Not only is Mac incapable of using any of the UITextContentType declarations, but it is also restricted to using NSTextContentType which currently only has three options. When I tried them out, I couldn’t see any difference in the suggestions they offer on the MacBook Touch Bar, but maybe I was doing it wrong somehow.

import SwiftUI
struct TextContentTypeView: View {
#if os(macOS)
let contentTypes: [NSTextContentType] = [.username, .password, .oneTimeCode]
#else
let contentTypes: [UITextContentType] = [.URL, .addressCity, .addressCityAndState, .addressState, .countryName, .creditCardNumber, .emailAddress, .familyName, .fullStreetAddress, .givenName, .jobTitle, .location, .middleName, .name, .namePrefix, .nameSuffix, .newPassword, .nickname, .oneTimeCode, .organizationName, .password, .postalCode, .streetAddressLine1, .streetAddressLine2, .sublocality, .telephoneNumber, .username]
#endif
@State var text = ""
var body: some View {
Form {
ForEach(contentTypes, id: \.self) {
contextType in
TextField("Enter text for \(contextType.rawValue)", text: $text)
.textContentType(contextType)
}
}
}
}

I noticed that there were no suggestions for usernames and passwords, which is probably unnecessary.

I didn’t have time to go through them all and note the intricacies of each, but I’ve provided an array of them so that my code sample isn’t too long.

See what differences you can find and let me know!

.listItemTint (NEW in 2.0)

Changing the tint of a list item has a different effect depending on the platform. To quote Apple’s official documentation on the new .listItemTint modifier:

The containing list’s style will apply that tint as appropriate. watchOS uses the tint color for its background platter appearance. Sidebars on iOS and macOS apply the tint color to their ItemLabel icons, which otherwise use the accent color by default.

After looking for something called ItemLabel, I realised that this does not seem to be something that exists. However, any item in a List can be a Label, so I tried that and it worked.

I’m not sure why they specified “Sidebars on iOS and macOS” either as it seems to work universally for any List, and iOS doesn’t have sidebars.

That’s iPadOS, it’s totally different!

import SwiftUI
public struct ListItemTintView: View {
public var body: some View {
NavigationView {
List {
Label(".listItemTint(.fixed(.accentColor))", systemImage: "gamecontroller")
.listItemTint(.fixed(.accentColor))
Label(".listItemTint(.fixed(Color.blue))", systemImage: "gamecontroller")
.listItemTint(.fixed(Color.blue))
Label(".listItemTint(.fixed(Color.red))", systemImage: "gamecontroller")
.listItemTint(.fixed(Color.red))
Label(".listItemTint(.monochrome)", systemImage: "gamecontroller")
.listItemTint(.monochrome)
Label(".listItemTint(.red)", systemImage: "gamecontroller")
.listItemTint(.red)
Text(".listItemTint(.fixed(.accentColor))")
.listItemTint(.fixed(.accentColor))
Text(".listItemTint(.fixed(Color.red))")
.listItemTint(.fixed(Color.red))
Text(".listItemTint(.monochrome)")
.listItemTint(.monochrome)
Text(".listItemTint(.red)")
.listItemTint(.red)
}
//Allows Sidebar on iOS and macOS
//.listStyle(SidebarListStyle())
//Content to show separate from Sidebar
Text("Content")
}
}
}

The fact that watchOS uses the tint for the background is a huge difference. The Label icon is not tinted at all, and the tint is used for any item, not just Label. The .listItemTint modifier can directly take a colour, but it can also take a structure called ListItemTint (with a capital letter). You can give it a .preferred variety, which can be overridden by a parent, or a .fixed variety, which cannot. While the .monochrome style of ListItemTint makes icons grey on iOS, on watchOS it makes the background of the list item black. On iOS, you’ll notice that the .accentColor and Color.blue variations are the same, while on watchOS the accent colour is grey and not blue.

.listRowPlatterColor (Deprecated in 2.0)

The new .listItemTint modifier above replaces .listRowPlatterColor.

This deprecated modifier was only available on WatchOS, and took only a colour. With .listItemTint, you can use ListItemTint, so you aren’t just limited to a colour. The new modifier overrides the old one too, so even if .listRowPlatterColor is used after .listItemTint, the colour passed to .listRowPlatterColor will be used instead.

.onLongPressGesture (Updated in 2.0)

It is now possible to use SwiftUI to add a long press gesture to a View on tvOS.

You need to be able to add focus to your View, so I’ve used the focusable() modifier. You can use this modifier with no parameters, but I’ve used it with a closure in order to change the colour of my custom buttons to show when they have focus. If I didn’t do this, there would be no indication of which button is selected at any given time. I’ve used an enum to contain four button states: unfocused, focused, pressing, and pressed. It isn’t necessary to have a closure for the pressing state, but I made this to show that you can run code before a long press has reached its minimum duration. The default duration is 0.5 seconds, so you probably only need to make changes to the UI if you also increase this duration.

import SwiftUI
enum ButtonState: String {
case unfocused, focused, pressing, pressed
var colour: Color {
switch self {
case .unfocused:
return .white
case .focused:
return .red
case .pressing:
return .orange
case .pressed:
return .green
}
}
}
struct ContentView: View {
@State var selectedIndex = 0
var body: some View {
VStack {
CustomButton(index: 0, currentIndex: $selectedIndex)
CustomButton(index: 1, currentIndex: $selectedIndex)
}
}
}
struct CustomButton: View {
let index: Int
@Binding var currentIndex: Int
@State var buttonState = ButtonState.unfocused
var isFocused: Bool {
return index == currentIndex
}
var focusedState: ButtonState {
return isFocused ? .focused : .unfocused
}
var body: some View {
Text(buttonState.rawValue)
.foregroundColor(.black)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(buttonState.colour)
.focusable(true) {
focused in if focused { currentIndex = index }
buttonState = focusedState
}
.onChange(of: currentIndex) {
currentIndex in buttonState = focusedState
}
.onLongPressGesture(minimumDuration: 2, pressing: { pressing in
buttonState = pressing ? .pressing : .focused
}) {
buttonState = .pressed
}
}
}

This code will work on any platform from 2019, but will only work on tvOS 14.0 from 2020.

.onOpenURL (NEW in 2.0)

This modifier has nothing to do with opening URLs for websites. These URLs are solely the ones that can be opened by your app, and your app alone.

The onOpenURL modifier is for SwiftUI apps that use the new SwiftUI App lifecycle that does not use AppDelegate or SceneDelegate. If your project has these files in it, you probably won’t be able to get this modifier to work, like I couldn’t. When creating a project, be sure to choose ‘SwiftUI App’ as the Life Cycle option instead of ‘UIKit App Delegate’.

To create a unique URL scheme for your app, select the Info tab of your project settings, whether that be for an iOS or macOS target. Without changing the Custom Target Properties at the top of the screen, you’ll see that there is already a URL types section at the bottom of the screen. Opening this and clicking the ‘+’ button will allow you to create a new URL scheme for your app, providing that it is unique and not the same as any other app on a user’s device. For the purposes of this example I have called it my-scheme, but you can choose anything.

The Identifier field is optional, but you may want to use it, as the URL type is simply referred to as Untitled in this menu without it.

Now that we have a URL scheme in place, we can do the rest in code. In the example below, I’ve added the ability to use a TextField to change what link is opened. The URL scheme you set up needs to be set as a constant in the ContentView struct. If you are using my-scheme as I was, you don’t need to do anything. I’ve extended URL, Character and String in order to filter the input from the TextField.

import SwiftUI
extension URL {
/// All symbols allowed in a URL
static let allowedCharacters = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ$-.,+!*()&#=/;@?:"
}
extension Character {
//Check a character is allowed when constructing a URL
var isAllowedInURL: Bool {
return URL.allowedCharacters.contains(self)
}
}
extension String {
/// Remove all characters not allowed in a URL from the string
/// - Precondition: String must not be empty and contain at least one character allowed in a URL
/// - Postcondition: A valid String for creating a URL
var urlString: String {
precondition(self.isEmpty == false, "String cannot be empty")
let returnValue = self.filter { $0.isAllowedInURL }
precondition(URL(string: returnValue) != nil, "urlString \(returnValue) could not be used to make a URL")
return returnValue
}
}
extension URL {
/// Creates a URL from the provided string after removing all illegal characters
/// - Precondition: String must not be empty and contain at least one character allowed in a URL
init(unsafeString: String) {
guard let url = URL(string: unsafeString.urlString) else {
preconditionFailure("Unsafe string \(unsafeString) was not made safe by String.urlString extension")
}
self = url
}
}
struct ContentView: View {
//Whatever scheme you entered in your project settings under URL types
let myURLScheme = "my-scheme://"
@State var text = ""
@State var openedURLs = [String]()
var fullURL: URL {
return URL(unsafeString: myURLScheme + text)
}
var body: some View {
Form {
HStack {
Text(myURLScheme)
TextField("Add text to URL", text: $text)
}
Link(destination: fullURL) {
Text("Open")
}
.onOpenURL { url in
openedURLs.append(url.absoluteString)
}
Section(header: Text("Opened URLs")) {
ForEach(0..<openedURLs.count, id: \.self) {
index in Text(openedURLs[index])
}
}
}
}
}

Because characters that are allowed in a URL are set as a constant called allowedCharacters, we can be certain that any text added to the TextField can successfully create a URL. If a URL was created with nothing but illegal characters, this code would filter them all out, leaving us with an empty string. This is one way that the code could fail, as a URL cannot be created with an empty string. Luckily we are applying the URL scheme separately, and the URL can be constructed with only the scheme and nothing else, so an empty string is fine in this case.

Whether you type a URL or not, the link that says “Open” will open that URL, and the onOpenURL modifier will add that URL to a list. Since we are doing this inside the app that those URLs are opened in, we don’t actually go anywhere. If you want to see a link actually do something, try entering a URL that starts with the URL scheme you set in Safari. This will ask you if you want to open the URL in your app, and then you will be taken back to the app.

If everything is working, the URLs you enter in Safari should also be added to the opened URLs list.

.onPasteCommand (NEW in 2.0)

This one took a long time to figure out. The official documentation for onPasteCommand says the modifier adds “an action to perform in response to the system’s Paste command.” But don’t go thinking that the closure you give to it will run when you paste into a TextField. The modifier only seems to work on SwiftUI Views, rather than the ones like TextField that wrap UIKit controls with UIViewRepresentable.

But how do you allow a View to accept pasting if it’s not a TextField? After all, trying to do this without a TextField causes an error sound, and the Edit > Paste option in the default menu bar is greyed out. The answer lies in a transcript from Session 231 of WWDC 2019, when the original version of this modifier was released:

However, I want to point out something that really makes onPaste different than onDrop. The first part is that there’s no location parameter in the closure. And that’s a key to what’s really going on here. When you do drag and drop, user is directly targeting via the cursor or the touch location which view should accept the drop but a paste command is more indirect. The user is either choosing paste from the menu or is perhaps using a keyboard shortcut or the great new gestures that exist in iOS. The way we solve the problem of knowing which view that the paste command should go to is with the focus system.

You must enable the keyboard navigation option in macOS System Preferences for this feature to work

If you follow the link the official documentation for onPasteCommand, you’ll notice that there is absolutely no mention of the focus system whatsoever. So what is the focus system? SwiftUI’s guide to Focus does a good job of explaining it, so I’ll just summarise the important parts that relate to this modifier. As shown on the left window of the screenshot above, focus requires the setting in System Preferences on macOS that allows you to navigate between focusable items with the tab button.

Now that you have that enabled, my code example should work.

Essentially, we have a Text at the top, and this automatically takes focus when the app runs. You’ll notice that it’s surrounded by a blue outline, assuming you have the default Accent colour of ‘Multicolor’ selected in System Preferences > General. If you have chosen a different Accent colour, the Text will be outlined in that colour. Now when you select Edit > Copy from the menu bar, or press the equivalent cmd+C key combination, we run a closure for copying to the clipboard.

This isn’t strictly necessary for using onPasteCommand, but it’s an easier way to control the Uniform Type Identifier for the data in the pasteboard. The main difference between onPasteCommand in 2020 and the original from 2019 is the existence of the UTType structure. In 2019, the types that the closure would accept were given as strings like “public.utf8-plain-text”.

This leaves a high probability of human error when typing the strings, and it is also not easy to find what strings apply to the type you want.

With the UTType structure, we have a huge number of constants that will likely cover anything you’re likely to want to paste. You can also make your own by conforming to the UTTypeContent and/or UTTypeData protocols. I’ve included a constant that shows an array of all the provided data types if you want to take a look. In the onCopyCommand closure, you might notice that we’re converting the string we want to copy to NSString.

Here’s a list of the types that conform to NSItemProviderWriting, and can, therefore, be used for pasting with an NSItemProvider:

import SwiftUI
import UniformTypeIdentifiers
@available(OSX 10.16, *)
@available(iOS, unavailable)
@available(tvOS, unavailable)
@available(watchOS, unavailable)
struct OnPasteCommandView: View {
/// The UTType we are expecting
/// ```
/// //Equivalent to this:
/// let utType = UTType("public.utf8-plain-text")!
/// ```
let utType = UTType.utf8PlainText
/// The text displayed when you paste
@State var text = "Where text goes"
/// The text displayed under the PasteButton
@State var buttonText = "Where text goes"
/// A string that will be coped in onCopyCommand
let copyString = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."
/// All the provided types that you can copy and paste
let utTypes: [UTType] = [.aiff, .aliasFile, .appleProtectedMPEG4Audio, .appleProtectedMPEG4Video, .appleScript, .application, .applicationBundle, .applicationExtension, .arReferenceObject, .archive, .assemblyLanguageSource, .audio, .audiovisualContent, .avi, .binaryPropertyList, .bmp, .bookmark, .bundle, .bz2, .cHeader, .cPlusPlusHeader, .cPlusPlusSource, .cSource, .calendarEvent, .commaSeparatedText, .compositeContent, .contact, .content, .data, .database, .delimitedText, .directory, .diskImage, .emailMessage, .epub, .exe, .executable, .fileURL, .flatRTFD, .folder, .font, .framework, .gif, .gzip, .heic, .heif, .html, .icns, .ico, .image, .internetLocation, .internetShortcut, .item, .javaScript, .jpeg, .livePhoto, .log, .m3uPlaylist, .message, .midi, .mountPoint, .movie, .mp3, .mpeg, .mpeg2TransportStream, .mpeg2Video, .mpeg4Audio, .mpeg4Movie, .objectiveCPlusPlusSource, .objectiveCSource, .osaScript, .osaScriptBundle, .package, .pdf, .perlScript, .phpScript, .pkcs12, .plainText, .playlist, .pluginBundle, .png, .presentation, .propertyList, .pythonScript, .quickLookGenerator, .quickTimeMovie, .rawImage, .realityFile, .resolvable, .rtf, .rtfd, .rubyScript, .sceneKitScene, .script, .shellScript, .sourceCode, .spotlightImporter, .spreadsheet, .svg, .swiftSource, .symbolicLink, .systemPreferencesPane, .tabSeparatedText, .text, .threeDContent, .tiff, .toDoItem, .unixExecutable, .usd, .usdz, .utf16ExternalPlainText, .utf16PlainText, .utf8PlainText, .utf8TabSeparatedText, .vCard, .video, .volume, .wav, .webArchive, .webP, .x509Certificate, .xml, .xmlPropertyList, .xmlPropertyList, .xpcService, .yaml, .zip]
/// Load the pasted data
/// - Parameters:
/// - array: The array of pasted items
/// - text: The text that should be updated with the result
func loadPastedString(from array: [NSItemProvider], to text: Binding<String>) {
guard let lastItem = array.last else {
assertionFailure("Nothing to paste")
return
}
lastItem.loadDataRepresentation(forTypeIdentifier: utType.identifier) {
(data, error) in
guard error == nil else {
assertionFailure("Could not load data: \(error.debugDescription)")
return
}
guard let data = data else {
assertionFailure("Could not load data")
return
}
text.wrappedValue = String(decoding: data, as: UTF8.self)
}
}
var body: some View {
VStack {
Text("Make sure you tick \nSystem Preferences > Keyboard > \nUse keyboard navigation to move focus between controls\n\nPress tab to go forward, shift + tab to go back\n")
Text("Press Cmd + C then press tab")
.padding()
.focusable()
.onCopyCommand {
[NSItemProvider(object: NSString(string: copyString))]
}
Text("Press Cmd + V then press tab")
.focusable()
.onPasteCommand(of: [self.utType]) {
array in loadPastedString(from: array, to: $text)
}
Text("onPasteCommand pasted below:\n\(text)")
.padding(.horizontal, 10)
.lineLimit(4)
Text("Press space or click Paste")
PasteButton(supportedContentTypes: [self.utType]) {
array in loadPastedString(from: array, to: $buttonText)
}
Text("PasteButton pasted below:\n\(buttonText)")
.padding(.horizontal, 10)
.lineLimit(4)
}
.frame(width: 400, height: 400)
}
}

I’ve also included an example of PasteButton so that you can see how similar onPasteCommand is.

The closure takes exactly the same array of UTType instances, although the parameter is labelled supportedContentTypes instead.

.onDrag and .onDrop (Updated in 2.0)

The two Views below have no awareness of one another. They don’t share an ObservableObject. The top one does not pass a Binding<String> to the bottom one, nor does it pass a constant to its initialiser. The only link between them is that the top applies an .onDrag modifier that provides data of the UTType.utf8PlainText variety, and the bottom applies an .onDrop modifier that expects UTType.utf8PlainText.

For more info about UTType, see .onPasteCommand above.

The TextField is used to generate a string, which is something unique that only one View knows about. Once it has been generated, it is displayed as an orange rectangle with rounded corners. This orange shape has been given an .onDrag modifier that provides an NSItemProvider for the underlying data, which has been converted to NSString because this class conforms to the NSItemProviderWriting protocol.

Providing some sort of NSItemProvider is all your View needs to do in order to become draggable, but you have to have somewhere to drop it in order for dragging to be useful.

That’s where .onDrop comes in.

import SwiftUI
import UniformTypeIdentifiers
struct DragAndDropView: View {
var body: some View {
GeometryReader { geometry in let height = geometry.size.height / 2
VStack {
DragView()
.frame(height: height)
DropView()
.frame(height: height)
}
}
}
}
struct TextWithBackground: View {
let text: String
var body: some View {
Text(text)
.padding()
.background(text.isEmpty ? Color.clear : Color.orange)
.cornerRadius(5)
}
}
struct DragView: View {
@State var text = ""
var body: some View {
VStack(spacing: 10) {
TextField("Enter text here", text: $text)
.padding()
.background(Color(UIColor.secondarySystemBackground))
Text(text.isEmpty ? "Type text above" : "Drag the text below")
Group {
TextWithBackground(text: text)
.onDrag { NSItemProvider(object: self.text as NSString) }
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
struct DropView: View {
@State var droppedText = ""
@State var dropEntered = false
let utType = UTType.utf8PlainText
var body: some View {
VStack {
Text(dropEntered ? "Drop text here" : "Drag text here")
Group {
TextWithBackground(text: droppedText)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.background(self.dropEntered ? Color.blue : Color(UIColor.secondarySystemBackground))
.onDrop(of: [utType], delegate: OnDropDelegate(text: $droppedText, dropEntered: $dropEntered))
}
}
struct OnDropDelegate: DropDelegate {
@Binding var text: String
@Binding var dropEntered: Bool
let utType = UTType.utf8PlainText
func dropEntered(info: DropInfo) {
self.dropEntered = true
}
func dropExited(info: DropInfo) {
self.dropEntered = false
}
func performDrop(info: DropInfo) -> Bool {
var returnValue = false
if let item = info.itemProviders(for: [utType]).first {
item.loadDataRepresentation(forTypeIdentifier: utType.identifier) {
(data, error) in
guard error == nil else {
assertionFailure("Could not load data: \(error.debugDescription)")
return
}
guard let data = data else {
assertionFailure("Could not load data")
return
}
text = String(decoding: data, as: UTF8.self)
dropEntered = false
returnValue = true
}
}
return returnValue
}
}

In DragView, we have a similar TextWithBackground which is hidden initially due to it having a clear background and no text.

Unlike .onDrag, .onDrop requires a delegate that conforms to the DropDelegate protocol. Unlike some delegate types, DropDelegate can be a structure and does not need to be a class. The only mandatory requirement of this protocol is that it has a function called performDrop, which does exactly what it sounds like. You attempt to read the data and, if successful, you return true to confirm that dragging happened. If there is an error at any point, you return false.

This is the only part of my code that is slightly complicated, but it bears a lot of similarity to the code used in PasteButton and .onPasteCommand above.

.onChange (NEW in 2.0)

One of the annoying things about the original version of SwiftUI is the lack of property observers. The only way to use something like didSet, which allows you to run a closure every time a property’s value changes, is to use it in an ObservableObject class. Since these are regular Swift classes, they cannot use the@Stateproperty wrapper, which only works on structures. But they can use property observers in the usual way that we expect, even on properties that are marked as @Published and are therefore accessible from SwiftUI.

Because structures are value types, and self is immutable, the structure needs to be completely recreated when changes occur. In the case of @State, the property is already being observed. Changes to this property cause the structure to be completely recreated since it cannot be mutated.

To quote the official documentation for the@Stateproperty wrapper:

A State instance isn’t the value itself; it’s a means of reading and writing the value. To access a state’s underlying value, use its variable name, which returns the wrappedValue property value.

So the property inside your structure is not actually the value you think it is — it’s a wrapper that actually saves it inside a totally separate and invisible structure. That’s why the documentation of the wrapper declares it as @propertyWrapper struct State<Value>, because it's not just modifying the behaviour of the property, it's storing it elsewhere. That’s why trying to use the didSet property observer doesn’t work: your instance of the property wrapper doesn’t ever change, only its wrappedValue property does.

So how do we do anything on the basis of these changes?

In the first year of SwiftUI’s existence, using property wrappers in an ObservableObject was about the only way. In 2020, we have a new modifier called .onChange, and this allows any View in your structure to run a closure when a @State property’s value changes. If a TextField has a Binding<String>, it can use the modifier to run code when the string is changed. But the View observing the changes does not need a direct Binding to react to changes, and the .onChange modifier for a Button in another part of the layout would have no more or less access to this ability.

import SwiftUI
struct ContentView: View {
@State var text = ""
@State var toggleIsOn = true
var body: some View {
VStack {
TextField("Enter some text", text: $text)
.onChange(of: toggleIsOn) { value in
if toggleIsOn && text.isEmpty {
text = "Here's some text"
}
else if !toggleIsOn {
text = ""
}
}
Toggle(isOn: $toggleIsOn) {
Text("TextField has text in it")
}
.onChange(of: text) { value in
toggleIsOn = !text.isEmpty
}
}
}
}

To make things a bit more interesting, my example uses a Binding instead of a State as the property for onChange to observe. The value that .onChange observes can actually be any type that conforms to Equatable. For a great look at all the ways that Equatable affects SwiftUI Views, check out SwiftUI Lab’s tutorial on EquatableView. Notice how ContentView, which does not contain an .onChange modifier, is still affected by the logic in the modifier of its child OnChangeView.

For instance, the TextField in ContentView is emptied when the Toggle in OnChangeView is turned off, despite it having no Binding to that control or even knowledge of its existence.

This is perhaps one of the most powerful capabilities of this new modifier, as it allows Views anywhere in the hierarchy to judge the situation and require that Views elsewhere should also be redrawn according to the properties that it cares about.

.keyboardShortcut (NEW in 2.0)

To quote Apple’s documentation for .keyboardShortcut:

Pressing the control’s shortcut while the control is anywhere in the frontmost window or scene, or anywhere in the macOS main menu, is equivalent to direct interaction with the control to perform its primary action.

Below is an example of two Buttons with associated keyboard shortcuts. The first button has .upArrow as the key, but no modifier is specified. You might think that this means pressing the up arrow will perform the action of the Button, but that would be wrong. The Command button is the default modifier, and so this Button actually requires the Cmd + Up combination to be pressed.

import SwiftUI
@available(iOS 14.0, OSX 10.16, tvOS 14.0, *)
@available(watchOS, unavailable)
struct KeyboardShortcutView: View {
var body: some View {
VStack {
Button("Press Cmd + Up") {
print("Button 1 pressed")
}
.keyboardShortcut(.upArrow)
Button("Press Shift + Down") {
print("Button 2 pressed")
}
.keyboardShortcut(.downArrow, modifiers: .shift)
}
.padding(20)
}
}

The second button does explicitly state a modifier, allowing it to use the shift key instead of command. If you read about the onPasteCommand modifier above, you would’ve read that on macOS there is a System Preferences option that enables navigating through focusable items with the tab key. If that option is enabled, you will find the top Button is highlighted by default. Tab will move focus to the second button, and Shift + Tab will move it back to the first.

No matter which Button is focused, the keyboard shortcuts you set will still work.

This is perhaps the most useful aspect of these shortcuts, as they continue to work despite the fact that the spacebar will perform the function of the currently focused Button.

.focusedValue and @FocusedBinding (NEW in 2.0)

This is a new way to pass data between Views. Instead of having an ObservableObject, we save data using a FocusedValueKey.

In the following example, DisplayTextView is able to show the text you type into TextFieldView, despite the fact that a Binding<String> or String constant is not passed between the Views.

struct ContentView: View {
var body: some View {
VStack {
TextFieldView()
DisplayTextView()
}
.padding(20)
}
}
struct TextFieldView: View {
@State var text = ""
var body: some View {
TextField("", text: $text)
.focusedValue(\.text, $text)
}
}
struct DisplayTextView: View {
@FocusedBinding(\.text) var text: String?
var body: some View {
Text(text ?? "Blank")
}
}
struct FocusedTextKey : FocusedValueKey {
typealias Value = Binding<String>
}
extension FocusedValues {
var text: FocusedTextKey.Value? {
get { self[FocusedTextKey.self] }
set { self[FocusedTextKey.self] = newValue }
}
}

The magic here is enabled by the structure and extension at the bottom.

The FocusedValueKey protocol requires that conforming structures have a typealias for the value they store.

Once you have a structure that defines a typealias for your key, you’ll need to define a getter and setter for that value. This extension of FocusedValue defines \.text as the key that we will use to read and write the value. Notice that the getter and setter both use the FocusedTextKey type as a subscript for FocusedValues. Now we just need to write a value to the key in TextFieldView, and then we need to read from it in DisplayTextView.

The .focusedValue(\.text, $text) modifier on the TextField saves the value to the key.

The @FocusedBinding(\.text) var text: String? property in DisplayTextView subscribes it to changes in the associated value. Notice that it is an optional, because the value does not have to be set. As Apple’s official documentation says, “Unlike EnvironmentKey, FocusedValuesHostKey has no default value requirement, because the default value for a key is always nil.” I assume the original name was FocusedValuesHostKey, because the documentation still mentions this despite the fact it no longer exists.

In other words, you can set up a key without giving it a value, and your code will still run.

When you do set up a value, you will need to unwrap it as I did using the nil coalescing operator ‘??’.

The next question you might have is why these values would be needed. After all, they seem to be global, at least in the context of a single window. We wouldn’t want to keep all our data at this scope, and having a lot of them might make it hard to debug. A more complex example by an Apple Frameworks Engineer on the Apple Developer Forums shows an interesting use case. When a Mac app has separate commands, such as Shift, Cmd + D in that example, you may still want to access data in the app despite the fact that the commands are at the WindowGroup scope.

Now you can!

.prefersDefaultFocus and .focusScope (NEW in 2.0)

On tvOS 14 and watchOS 7, we now have the ability to declare what user interface element we want to be focused by default. On tvOS, this matters because pressing the Touch surface of the Siri remote performs the action of the Button with focus. On watchOS, the focused element is controlled by moving the Digital Crown.

In a VStack, the top View will always gain focus by default, unless we take steps to prevent this behaviour.

To do this, we need to define the scope in which the focus system can be overridden. Declaring a focus scope requires a @Namespace, a subject on which there’s more detail in the .matchedGeometryEffect section above. The important thing is that I gave a Namespace.ID with my property, which I also called namespace. I passed this to the .focusScope modifier on the VStack, and now we have our scope.

import SwiftUI
@available(iOS, unavailable)
@available(OSX, unavailable)
@available(tvOS 14.0, *)
@available(watchOS 6.0, *)
struct ContentView: View {
@Namespace var namespace
var body: some View {
VStack {
Button("Usually the default"){
print("Top button pressed")
}
Button("Prefers default"){
print("Bottom button pressed")
}
.prefersDefaultFocus(in: namespace)
}
.focusScope(namespace)
}
}

The only other thing I need to do is use the .prefersDefaultFocus modifier on the bottom Button, passing it the namespace.

Now the bottom Button will be focused by default, despite the fact that it is not at the top of the VStack.

.fullScreenCover (NEW in 2.0)

You probably won’t be shocked to learn that, unlike .sheet, the .fullScreenCover modifier presents a modal View that covers the full screen. I made an example that allows you to infinitely create sheets and full-screen covers, as this shows you important information about how they work. A sheet can be swiped to dismiss it, but a full-screen cover cannot. A View can allow both kinds of modal, and the modal itself can be identical in either case.

import SwiftUI
struct ContentView: View {
var body: some View {
ModalView(canDismiss: false)
}
}
struct ModalView: View {
@Environment(\.presentationMode) var presentationMode
@State var fullScreenCovered = false
@State var sheetIsPresented = false
let canDismiss: Bool
var body: some View {
VStack {
Button("Full screen") {
fullScreenCovered.toggle()
}
.fullScreenCover(isPresented: $fullScreenCovered) {
ModalView(canDismiss: true)
.background(Color.yellow)
}
Button("Sheet") {
sheetIsPresented.toggle()
}
.sheet(isPresented: $sheetIsPresented) {
ModalView(canDismiss: true)
.background(Color.orange)
}
if canDismiss {
Button("Dismiss") {
presentationMode.wrappedValue.dismiss()
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}

I have provided the @Environment(\.presentationMode) property in the modal, as without swiping to dismiss, the full-screen cover could not be dismissed.

.defaultAppStorage (NEW in 2.0)

This modifier .defaultAppStorage changes what UserDefaults an entire View’s @AppStorage properties are saved to. If you’re unfamiliar with @AppStorage or UserDefaults, this is a way of saving simple information that persists after the user has quit the app. The @AppStorage property wrapper will not be covered specifically here, but it will be covered as part of the State and Data Flow chapter of this revised version of my documentation.

To quote Axel Kee’s post When to use UserDefaults, Keychain, or Core Data:

Previously, we have explained that UserDefaults saves data into plist. Using apps such as iExplorer, users can access the Library/Preferences folder of their iPhone and read / modify the UserDefaults plist data easily (eg: Change the boolean value of “boughtProVersion” from false to true, or change the amount of coins). Don’t ever store a boolean for checking if user has bought in-app purchase in UserDefaults! User can change it very easily (without jailbreaking) and get your goodies for free! 😬

Other than in-app purchase status, you shouldn’t store user password / API Keys in UserDefaults for the same reason as well.

import SwiftUI
struct ContentView: View {
var body: some View {
VStack {
StandardAppStorageView()
.defaultAppStorage(UserDefaults(suiteName: "group.YourGroupName.YourApp") ?? .standard)
GroupAppStorageView()
.defaultAppStorage(.standard)
}
}
}
struct GroupAppStorageView: View {
@AppStorage("Text") var text = ""
//Equivalent to:
//@AppStorage("Text", store: UserDefaults(suiteName: "group.YourGroupName.YourApp") ?? .standard) var text = ""
var body: some View {
VStack {
TextField("Enter text", text: $text)
Text(text)
}
}
}
struct StandardAppStorageView: View {
@AppStorage("Text") var text = ""
var body: some View {
VStack {
TextField("Enter text", text: $text)
Text(text)
}
}
}

.appStoreOverlay (NEW in 2.0)

In order to recommend an app made by yourself or others, you need to know the 10-digit App ID. This is relatively easy if you own the app, as you’ll find it in the App Information section of App Store Connect. However, if you don’t know the App ID for an app, head to iTunes Link Maker and search for it, making sure to change the Media Type to Apps so you don’t just get a bunch of music. Whatever app you choose will give you a direct link, which ends in a number followed by a query.

The 10-digit code is found between the letters ‘id’ and the question mark ‘?’.

For instance, the Apple Developer app has the link:
https://apps.apple.com/us/app/apple-developer/id640199958?mt=8

The App ID is therefore 640199958.

import SwiftUI
import StoreKit
struct AppStoreOverlayView: View {
@State var isPresented = false
@State var appID = "1440427080"
@State var raised = false
func togglePresentation() {
//Dismiss keyboard
UIApplication.shared.windows.forEach { $0.endEditing(false) }
//Show/Hide overlay
isPresented.toggle()
}
var body: some View {
VStack {
Toggle(isOn: $raised) {
Text("Raised position")
}
.onChange(of: self.raised) { _ in
togglePresentation()
}
HStack {
TextField("Enter an App ID", text: $appID)
.keyboardType(.numberPad)
Button(isPresented ? "Dismiss" : "Open") {
togglePresentation()
}
.appStoreOverlay(isPresented: $isPresented) {
SKOverlay.AppConfiguration(appIdentifier: appID, position: raised ? SKOverlay.Position.bottomRaised : SKOverlay.Position.bottom)
}
}
}
.padding()
}
}

There’s a screenshot of my example at the start of this section. I added a TextField, into which you can type or paste an App ID, a Button to toggle the appearance of the App Store overlay, and a Toggle to change the position.

There are only two positions for an App Store overlay: .bottom and .bottomRaised, hence the name for the Toggle being ‘raised’.

.toolbar (NEW in 2.0)

import SwiftUI
public struct ToolbarView: View {
@State var data: [String]
init() {
var data = [String]()
for _ in 1...5 {
data.append(UUID().uuidString)
}
self._data = State<[String]>(initialValue: data)
}
public var body: some View {
NavigationView {
List(data, id: \.self) { uuid in
Text(uuid)
.lineLimit(1)
}
.navigationTitle("UUID Generator")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("Add") {
data.append(UUID().uuidString)
}
.padding(5)
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(5)
}
ToolbarItem(placement: .navigationBarLeading) {
Button("Delete All") { data.removeAll() }
}
ToolbarItem(placement: .bottomBar) {
Button("Sort A > Z") { data.sort() }
}
ToolbarItem(placement: .bottomBar) {
Button("Sort A > Z") { data.sort(by: >) }
}
}
}
}
}

Toolbars can now be easily created on all platforms using ToolBarItems. These can be Buttons or any View.

In terms of placing a ToolBarItem, there are many options.

Instead of rewording what he said, I’m going to quote Majid’s excellent post Mastering toolbars in SwiftUI:

automatic — The item is placed in the default section that varies depending on the current platform.

primaryAction — The item represents a primary action. Usually, SwiftUI places this item in the navigation bar on iOS or on top of other views on watchOS.

There are placement options that we can use only in toolbars presented by a modal view.

confirmationAction — The item represents a confirmation action for a modal interface. You can use it in your sheets to confirm saving action.

cancellationAction — The item represents a cancellation action for a modal interface.

destructiveAction — The item represents a destructive action for a modal interface. You can use it in your modal screens that delete some data.

There are also a bunch of platform-specific placement options.

bottomBar — The item is placed in the bottom toolbar. It is available only on iOS.

navigationBarLeading — The item is placed in the leading area of the navigation bar. It is available only on iOS and macOS.

navigationBarTrailing — The item is placed in the trailing area of the navigation bar. It is available only on iOS and macOS.

.previewContext (NEW in 2.0)

When you want to preview one of the widgets that you can make with the new WidgetKit framework, you run into a problem. The previews that might work for other SwiftUI views show a full-screen app on a device, or a custom size and shape if you choose .previewLayout(.fixed(width: 300, height: 300)).

Instead of us needing to manually choose a size that matches what a widget looks like, we now have a new modifier that allows us to choose from the three sizes that widgets can be.

As far as I know, these are the only options for .previewContext:

  • .previewContext(WidgetPreviewContext(family: .systemSmall))
  • .previewContext(WidgetPreviewContext(family: .systemMedium))
  • .previewContext(WidgetPreviewContext(family: .systemLarge))

I made an example that includes an actual widget, if you want to see the code for that.

import SwiftUI
import WidgetKit
extension Date {
var timeString: String {
let hour = Calendar.current.component(.hour, from: self)
let minutes = Calendar.current.component(.minute, from: self)
return "\(hour):\(minutes < 10 ? "0\(minutes)" : "\(minutes)")"
}
}
struct ContentView: View {
@Environment(\.widgetFamily) var family
var text: String {
switch family {
case .systemSmall: return "Small size"
case .systemMedium: return "Medium size"
case .systemLarge: return "Large size"
default: return "Default size"
}
}
var backgroundColor: Color {
switch family {
case .systemSmall: return .red
case .systemMedium: return .green
case .systemLarge: return .blue
default: return .orange
}
}
var body: some View {
Text("\(Date().timeString)\n\(text)")
.font(.largeTitle)
.fontWeight(.bold)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(backgroundColor)
.multilineTextAlignment(.center)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
.previewContext(WidgetPreviewContext(family: .systemSmall))
ContentView()
.previewContext(WidgetPreviewContext(family: .systemMedium))
ContentView()
.previewContext(WidgetPreviewContext(family: .systemLarge))
}
}

My example above uses a Date extension to get a string for the time. There isn’t much reason for this, but in a real widget you are required to have a Date object in your TimelineEntry structures. The rest of the View uses the environment variable \.widgetFamily to access the value we passed to the WidgetPreviewContext. I am able to use computed properties based on this value forbackgroundColor and text, so the View ends up completely different in each case.

When I tried on Xcode 12 beta 2, I was unable to use a switch statement inside the body property. When I did, even if I had the same View based on the computed properties in each case of the switch statement, I would get the ‘medium’ colour and text for each of the previews, even though they were all displayed as differently sized widgets. This might be fixed by the time you read this.

What about if you want to make your own PreviewContext? I tried my best, but it’s somewhat difficult due to the inaccessible nature of Apple’s WidgetPreviewContext implementation. The protocol requires that you allow subscripts from instances of a structure that conforms to PreviewContextKey, and you return a value that matches the typealias inside that key structure. That type could be literally anything, as it has no protocol requirements or restrictions of any kind.

When I tried to make my own PreviewContext, I wanted the ability to make widgets way too big. Widgets have a maximum size, so I thought it would be interesting to try and display them larger than they could ever be. To do this, I used an enum, just like the WidgetFamily. The enum has a computed property called size, and this will be used to define the size of my widget previews. Instead of using the subscript, I decided to just make a new version of the previewContext modifier.

After all, we have no way of knowing how the original modifier actually works.

struct LargePreviewContextKey: PreviewContextKey {
static var defaultValue: Custom = .veryLarge
typealias Value = Custom
}
enum Custom {
case large, veryLarge, tooBig
var size: CGSize {
switch self {
case .large:
return CGSize(width: 400, height: 400)
case .veryLarge:
return CGSize(width: 500, height: 500)
case .tooBig:
return CGSize(width: 600, height: 600)
}
}
}
struct LargePreviewContext: PreviewContext {
subscript<Key>(key: Key.Type) -> Key.Value where Key : PreviewContextKey {
return key.defaultValue
}
let custom: Custom
public init(family: Custom) {
self.custom = family
}
}
extension View {
public func previewContext<C>(custom: C) -> some View where C : PreviewContext {
return Group {
if let context = custom as? LargePreviewContext {
self.previewLayout(.fixed(width: context.custom.size.width, height: context.custom.size.height))}
else {
self.previewContext(custom)
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
.previewContext(LargePreviewContext(family: .veryLarge))
ContentView()
.previewContext(custom: LargePreviewContext(family: .veryLarge))
}
}

My custom version of .previewContext optionally binds the custom value passed to it, casting it to the my LargePreviewContext type. When this succeeds, I get the size for the preview from the Custom enum case stored there, and return the view with a custom preview of that size and shape. If the optional binding fails, I simply use the regular version of previewContext.

You might notice that my PreviewProvider at the bottom uses both kinds of previewContext. Using the system version, my custom PreviewContext is completely ignored. Using my custom version, it is made successfully. This was my attempt at replicating the basic underlying principles of these protocols. It may be that Apple doesn’t store any size information in these structures, as accessing the WidgetFamily enum case inside might provide the system with enough information to provide the size from elsewhere.

However it works under the surface, .previewContext is a mysterious new capability that might lead to more ways to customise previews in the future.

If you figure out a better way to make use of it, let me know!

.userActivity and .onContinueUserActivity (NEW in 2.0)

Now that we can make make SwiftUI apps without an App Delegate, we need to be able to add the functionality that would usually be there. That is, assuming we do not take steps to add App Delegate to a SwiftUI app. The .onOpenURL modifier is anexample of another closure that takes an action when the app resumes.

The main use for .onContinueUserActivity that I found was opening the app from a Spotlight search. The example below creates a list displaying 10 UUIDs. These unique identifiers will be used to identify each item when they become searchable. When the app starts, the UUIDs are generated and saved to UserDefaults.

Only when a Button in the List is tapped is the UUID indexed, creating a CSSearchableItem and assigning it a CSSearchableIndex.

If this function prints “saved successfully”, the UUID you chose has been indexed.

I found it difficult to get my UUIDs to appear in search, but that might be because I didn’t add search terms. To do this, use the keywords property of your CSSearchableItemAttributeSet, which is just an optional array of strings. Without doing that, I managed to still find my UUIDs by searching the name of my app. Although the app itself will come up, you will also see an option to search within the app, shown as a search result with your your app’s name next to a magnifying glass icon.

Tapping this should bring up any UUIDs you have indexed.

import SwiftUI
import CoreSpotlight
import MobileCoreServices
struct UserActivityView: View {
@State var data: [String]
@State var openedID = String()
@State var alertPresented = false
init() {
if let stringArray = UserDefaults.standard.stringArray(forKey: "data") {
self._data = State<[String]>(initialValue: stringArray)
}
else {
var data = [String]()
for _ in 1...10 {
data.append(UUID().uuidString)
}
self._data = State<[String]>(initialValue: data)
UserDefaults.standard.set(data, forKey: "data")
}
}
func indexItem(atIndex index: Int) {
let uuid = data[index]
let attributeSet = CSSearchableItemAttributeSet(itemContentType: kUTTypeText as String)
attributeSet.title = "A search item for my app"
attributeSet.contentDescription = uuid
let item = CSSearchableItem(uniqueIdentifier: uuid, domainIdentifier: "com.MyCompany", attributeSet: attributeSet)
CSSearchableIndex.default().indexSearchableItems([item]) { error in
if let error = error {
assertionFailure("Failed to index with error\n\(error)")
} else {
print("Saved successfully")
}
}
}
func presentAlert(_ userActivity: NSUserActivity) {
if let id = userActivity.userInfo?[CSSearchableItemActivityIdentifier] as? String {
openedID = id
alertPresented = true
}
}
var body: some View {
List(0..<data.count, id: \.self) { index in
Button("\(data[index])") {
indexItem(atIndex: index)
}
}
.userActivity("com.MyCompany.MyApp.searchActivity", element: openedID) {
string, activity in
if !string.isEmpty {print("Updated \(string) \(activity.activityType)")}
}
.onContinueUserActivity("com.MyCompany.MyApp.searchActivity") { activity in
print("Continue after Handoff")
}
.alert(isPresented: $alertPresented) {
Alert(title: Text("onContinueUserActivity"), message: Text(openedID), dismissButton: .default(Text("Okay")))
}
.onContinueUserActivity(CSSearchableItemActionType, perform: presentAlert)
}
}

When you tap an item that has been indexed, the app will open. Without an App Delegate, how do we know what to do when it does? The .onContinueUserActivity modifier does exactly that, giving a function that will run when any search activity opens the app. I didn’t need to pass a function though, as I could easily have used a trailing closure instead. The important thing is that I optionally bind the ID of the item so that I can be sure I have a UUID.

I set the UUID to a State property and present an alert that displays that data for you to see. The UUID should match the description of the item you selected in search.

I have provided an empty implementation of the version of .onContinueUserActivity that takes a string for activityType instead, although I was unable to get it working.

The .userActivity modifier “advertises” an NSUserActivity. You can see that this updates when the UUID changes. In my case, I found there was a long delay of about 30 seconds before the change was printed here. This is when your activity, whatever it is, would be available for handoff if that was implemented.

In any case, the important thing to know about these modifiers is that they relate to NSUserActivity, Handoff and Spotlight Search.

.tabItem (Updated in 2.0)

There isn’t a lot to say about this one, so I’ll post Apple’s example with the addition of the @available attribute at the top.

Notice anything?

@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 7.0, *)
struct TabItem: View {
var body: some View {
TabView {
View1()
.tabItem {
Image(systemName: "list.dash")
Text("Menu")
}
View2()
.tabItem {
Image(systemName: "square.and.pencil")
Text("Order")
}
}
}
}
struct View1: View {
var body: some View {
Text("View 1")
}
}
struct View2: View {
var body: some View {
Text("View 2")
}
}

TabView, along with the modifier .tabItem that allows you to create the icon that represents that page on the tab bar, is new to WatchOS. Although it was available on Mac, iOS, iPadOS and tvOS last year, it has only just come to the Watch this year. What form could it possibly take, you might ask? It resembles a UIPageViewController from UIKit, with each page requiring you to swipe horizontally from one to the other. The although the .tabItem modifier exists, neither the Text nor the Image that Apple’s example provides are visible.

Instead we get dots, much in the same way that UIPageViewController makes use of a UIPageControl, which Apple describes as "a horizontal series of dots, each of which corresponds to a page in the app’s document or other data-model entity.”

.contextMenu (Updated in 2.0)

Like .tabItem above, .contextMenu was new last year but has come to a new platform this year, and like .tabItem above, I've used one of Apple’s examples. This time I put in a little more effort, as adapting their example for tvOS actually requires that the Text be made .focusable, which is covered in detail earlier in this post. Once a View can become the focused element on the screen, it can also receive a long press gesture. If it has a .contextMenu modifier, this brings up the list of options you provided to it.

The fact that I was able to use a Text means that you don’t need a Button to do this, but as I said the View you use must use the .focusable() modifier for this to work.

Here’s how I did it.

import SwiftUI
struct ContentView: View {
@State var selectedIndex = -1
var selection: String {
switch selectedIndex {
case 0: return "♥️"
case 1: return "♣️"
case 2: return "♠️"
case 3: return "♦️"
default: return ""
}
}
var body: some View {
VStack {
Text("Favourite card suit:")
Text(selection)
.font(.system(size: 200))
.focusable()
.padding()
.contextMenu {
Button("♥️ - Hearts") {selectedIndex = 0}
Button("♣️ - Clubs") {selectedIndex = 1}
Button("♠️ - Spades") {selectedIndex = 2}
Button("♦️ - Diamonds") {selectedIndex = 3}
}
Text("Long press to make a choice")
}
}
}

.navigationTitle and .navigationSubtitle (NEW in 2.0)

I can’t do much better than Apple’s official documentation for navigationTitlethis time:

A view’s navigation title is used to visually display the current navigation state of an interface. On iOS and watchOS, when a view is navigated to inside of a navigation view, that view’s title is displayed in the navigation bar. On iPadOS, the primary destination’s navigation title is reflected as the window’s title in the App Switcher. Similarly on macOS, the primary destination’s title is used as the window title in the titlebar, Windows menu and Mission Control.

And here’s Apple’s official documentation for navigationSubtitle:

A view’s navigation subtitle is used to provide additional contextual information alongside the navigation title. On macOS, the primary destination’s subtitle is displayed with the navigation title in the titlebar.

My example goes through the options for navigationBarTitleDisplayMode with a Picker, so you can see what they all look like. I restricted this example to iOS, because NavigationBarItem.TitleDisplayMode options are compatible with macOS.

import SwiftUI
#if os(iOS)
struct ContentView: View {
@State var barStyle = BarStyle.automatic
enum BarStyle: String, CaseIterable {
case automatic, inline, large
var style: NavigationBarItem.TitleDisplayMode {
switch self {
case .automatic:
return .automatic
case .inline:
return .inline
case .large:
return .large
}
}
}
var body: some View {
Group {
List {
Picker(selection: $barStyle, label: Text(".navigationBarTitleDisplayMode")) {
ForEach(BarStyle.allCases, id: \.self) {
barStyle in Text(barStyle.rawValue)
}
}.pickerStyle(SegmentedPickerStyle())
ForEach(0...50, id: \.self) { _ in
Text("Example list item")
}
}
.navigationTitle(".navigationTitle")
.navigationBarTitleDisplayMode(barStyle.style)
}
}
}
#endif

.navigationViewStyle (Updated in 2.0)

WatchOS now has the ability to use .navigationViewStyle, but it seems the only provided value for it is StackNavigationViewStyle. The only other option on any platform isDoubleColumnNavigationViewStyle, and you can bet that's not coming to WatchOS any time soon!

.navigationBarTitle (Deprecated in 2.0)

There is, in fact, no navigation bar on macOS, which is one of the reasons why the more generic .navigationTitle is replacing .navigationBarTitle. Not all of the pages of the documentation where .navigationBarTitle appears show it as deprecated, but the one for Text does. It seems like there may be mistakes where it isn’t deprecated everywhere even though it should be. For instance, it is currently not deprecated on Mac Catalyst 13.0, but this seems to make little sense if it’s deprecated on iOS 13.0.

.navigationBarItems (Deprecated in 2.0)

To add a Button to the leading or trailing positions of a navigation bar, use a toolbar with ToolbarItem(placement: .navigationBarLeading) and/or a ToolbarItem(placement: .navigationBarTrailing).

Styles on iOS, iPadOS, Mac Catalyst and tvOS (NEW in 2.0)

Here’s an example that uses both .labelStyle and .indexViewStyle, which are both new in 2020 and unavailable on macOS:

import SwiftUI
@available(OSX, unavailable)
struct TabIndexStyleView: View {
@State private var selected = 1
var body: some View {
VStack {
TabView(selection: self.$selected) {
Label("\(selected)", systemImage: "gamecontroller")
.labelStyle(DefaultLabelStyle())
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.red)
.tag(1)
Label("\(selected)", systemImage: "gamecontroller")
.labelStyle(IconOnlyLabelStyle())
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.green)
.tag(2)
Label("\(selected)", systemImage: "gamecontroller")
.labelStyle(TitleOnlyLabelStyle())
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.blue)
.tag(3)
}
.indexViewStyle(PageIndexViewStyle(backgroundDisplayMode: .always))
.tabViewStyle(PageTabViewStyle())
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}

Here are the options for PageIndexViewStyle, which is the only thing you can pass to .indexViewStyle at the moment:

  • .automatic: Background will use the default for the platform
  • .interactive: Background is only shown while the index view is interacted with
  • .always: Background is always displayed behind the page index view
  • .never: Background is never displayed behind the page index view

I couldn’t get them to work, but here they are! These styles relate to the window that is presented, so I can only assume that it’s the window you present in front of your View. The .groupBoxStyle modifier is new, but we can only use DefaultGroupBoxStyle with it unless we make our own custom ones.

import SwiftUI
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.groupBoxStyle(DefaultGroupBoxStyle())
.presentedWindowStyle(TitleBarWindowStyle())
.presentedWindowStyle(HiddenTitleBarWindowStyle())
.presentedWindowToolbarStyle(UnifiedWindowToolbarStyle())
.presentedWindowToolbarStyle(ExpandedWindowToolbarStyle())
.presentedWindowToolbarStyle(UnifiedCompactWindowToolbarStyle())
}
}
}

The names of these are at least clear enough that you can imagine roughly what they do, even if I couldn’t give a working example.

Next Steps

SwiftUI is only a year old as I’m writing this, and there are already a wealth of resources out there. My writing would not be possible without the following websites:

As I said at the start of the post, If you have requests for more detail on a subject, or if you think I’ve made a mistake, let me know in a response below.

Thanks for reading!

댓글 없음:

댓글 쓰기