Rodhos Soft

備忘録を兼ねた技術的なメモです。Rofhos SoftではiOSアプリ開発を中心としてAndroid, Webサービス等の開発を承っております。まずはご相談下さい。

フォント指定

stackoverflow.com

指定できるフォントを調べるには

        for family in UIFont.familyNames.sorted() {
            let names = UIFont.fontNames(forFamilyName: family)
            print("Family: \(family) Font names: \(names)")
        }

フォントの指定は

                Text("acccdefああ投稿").font(.custom("CourierNewPS-ItalicMT", size: 20))

のようにする。

先程のフォント名一覧はこのようにでた。

Family: Academy Engraved LET Font names: ["AcademyEngravedLetPlain"]
Family: Al Nile Font names: ["AlNile", "AlNile-Bold"]
Family: American Typewriter Font names: ["AmericanTypewriter-CondensedBold", "AmericanTypewriter-Condensed", "AmericanTypewriter-CondensedLight", "AmericanTypewriter", "AmericanTypewriter-Bold", "AmericanTypewriter-Semibold", "AmericanTypewriter-Light"]
Family: Apple Color Emoji Font names: ["AppleColorEmoji"]
Family: Apple SD Gothic Neo Font names: ["AppleSDGothicNeo-Thin", "AppleSDGothicNeo-Light", "AppleSDGothicNeo-Regular", "AppleSDGothicNeo-Bold", "AppleSDGothicNeo-SemiBold", "AppleSDGothicNeo-UltraLight", "AppleSDGothicNeo-Medium"]
Family: Apple Symbols Font names: ["AppleSymbols"]
Family: Arial Font names: ["Arial-BoldMT", "Arial-BoldItalicMT", "Arial-ItalicMT", "ArialMT"]
Family: Arial Hebrew Font names: ["ArialHebrew-Bold", "ArialHebrew-Light", "ArialHebrew"]
Family: Arial Rounded MT Bold Font names: ["ArialRoundedMTBold"]
Family: Avenir Font names: ["Avenir-Oblique", "Avenir-HeavyOblique", "Avenir-Heavy", "Avenir-BlackOblique", "Avenir-BookOblique", "Avenir-Roman", "Avenir-Medium", "Avenir-Black", "Avenir-Light", "Avenir-MediumOblique", "Avenir-Book", "Avenir-LightOblique"]
Family: Avenir Next Font names: ["AvenirNext-Medium", "AvenirNext-DemiBoldItalic", "AvenirNext-DemiBold", "AvenirNext-HeavyItalic", "AvenirNext-Regular", "AvenirNext-Italic", "AvenirNext-MediumItalic", "AvenirNext-UltraLightItalic", "AvenirNext-BoldItalic", "AvenirNext-Heavy", "AvenirNext-Bold", "AvenirNext-UltraLight"]
Family: Avenir Next Condensed Font names: ["AvenirNextCondensed-Heavy", "AvenirNextCondensed-MediumItalic", "AvenirNextCondensed-Regular", "AvenirNextCondensed-UltraLightItalic", "AvenirNextCondensed-Medium", "AvenirNextCondensed-HeavyItalic", "AvenirNextCondensed-DemiBoldItalic", "AvenirNextCondensed-Bold", "AvenirNextCondensed-DemiBold", "AvenirNextCondensed-BoldItalic", "AvenirNextCondensed-Italic", "AvenirNextCondensed-UltraLight"]
Family: Baskerville Font names: ["Baskerville-SemiBoldItalic", "Baskerville-SemiBold", "Baskerville-BoldItalic", "Baskerville", "Baskerville-Bold", "Baskerville-Italic"]
Family: Bodoni 72 Font names: ["BodoniSvtyTwoITCTT-Bold", "BodoniSvtyTwoITCTT-BookIta", "BodoniSvtyTwoITCTT-Book"]
Family: Bodoni 72 Oldstyle Font names: ["BodoniSvtyTwoOSITCTT-BookIt", "BodoniSvtyTwoOSITCTT-Book", "BodoniSvtyTwoOSITCTT-Bold"]
Family: Bodoni 72 Smallcaps Font names: ["BodoniSvtyTwoSCITCTT-Book"]
Family: Bodoni Ornaments Font names: ["BodoniOrnamentsITCTT"]
Family: Bradley Hand Font names: ["BradleyHandITCTT-Bold"]
Family: Chalkboard SE Font names: ["ChalkboardSE-Bold", "ChalkboardSE-Light", "ChalkboardSE-Regular"]
Family: Chalkduster Font names: ["Chalkduster"]
Family: Charter Font names: ["Charter-BlackItalic", "Charter-Bold", "Charter-Roman", "Charter-Black", "Charter-BoldItalic", "Charter-Italic"]
Family: Cochin Font names: ["Cochin-Italic", "Cochin-Bold", "Cochin", "Cochin-BoldItalic"]
Family: Copperplate Font names: ["Copperplate-Light", "Copperplate", "Copperplate-Bold"]
Family: Courier Font names: ["Courier-BoldOblique", "Courier-Oblique", "Courier", "Courier-Bold"]
Family: Courier New Font names: ["CourierNewPS-ItalicMT", "CourierNewPSMT", "CourierNewPS-BoldItalicMT", "CourierNewPS-BoldMT"]
Family: DIN Alternate Font names: ["DINAlternate-Bold"]
Family: DIN Condensed Font names: ["DINCondensed-Bold"]
Family: Damascus Font names: ["DamascusBold", "DamascusLight", "Damascus", "DamascusMedium", "DamascusSemiBold"]
Family: Devanagari Sangam MN Font names: ["DevanagariSangamMN", "DevanagariSangamMN-Bold"]
Family: Didot Font names: ["Didot-Bold", "Didot", "Didot-Italic"]
Family: Euphemia UCAS Font names: ["EuphemiaUCAS", "EuphemiaUCAS-Italic", "EuphemiaUCAS-Bold"]
Family: Farah Font names: ["Farah"]
Family: Futura Font names: ["Futura-CondensedExtraBold", "Futura-Medium", "Futura-Bold", "Futura-CondensedMedium", "Futura-MediumItalic"]
Family: Galvji Font names: ["Galvji-Bold", "Galvji"]
Family: Geeza Pro Font names: ["GeezaPro-Bold", "GeezaPro"]
Family: Georgia Font names: ["Georgia-BoldItalic", "Georgia-Italic", "Georgia", "Georgia-Bold"]
Family: Gill Sans Font names: ["GillSans-Italic", "GillSans-SemiBold", "GillSans-UltraBold", "GillSans-Light", "GillSans-Bold", "GillSans", "GillSans-SemiBoldItalic", "GillSans-BoldItalic", "GillSans-LightItalic"]
Family: Helvetica Font names: ["Helvetica-Oblique", "Helvetica-BoldOblique", "Helvetica", "Helvetica-Light", "Helvetica-Bold", "Helvetica-LightOblique"]
Family: Helvetica Neue Font names: ["HelveticaNeue-UltraLightItalic", "HelveticaNeue-Medium", "HelveticaNeue-MediumItalic", "HelveticaNeue-UltraLight", "HelveticaNeue-Italic", "HelveticaNeue-Light", "HelveticaNeue-ThinItalic", "HelveticaNeue-LightItalic", "HelveticaNeue-Bold", "HelveticaNeue-Thin", "HelveticaNeue-CondensedBlack", "HelveticaNeue", "HelveticaNeue-CondensedBold", "HelveticaNeue-BoldItalic"]
Family: Hiragino Maru Gothic ProN Font names: ["HiraMaruProN-W4"]
Family: Hiragino Mincho ProN Font names: ["HiraMinProN-W3", "HiraMinProN-W6"]
Family: Hiragino Sans Font names: ["HiraginoSans-W3", "HiraginoSans-W6", "HiraginoSans-W7"]
Family: Hoefler Text Font names: ["HoeflerText-Italic", "HoeflerText-Black", "HoeflerText-Regular", "HoeflerText-BlackItalic"]
Family: Kailasa Font names: ["Kailasa-Bold", "Kailasa"]
Family: Kefa Font names: ["Kefa-Regular"]
Family: Khmer Sangam MN Font names: ["KhmerSangamMN"]
Family: Kohinoor Bangla Font names: ["KohinoorBangla-Regular", "KohinoorBangla-Semibold", "KohinoorBangla-Light"]
Family: Kohinoor Devanagari Font names: ["KohinoorDevanagari-Regular", "KohinoorDevanagari-Light", "KohinoorDevanagari-Semibold"]
Family: Kohinoor Gujarati Font names: ["KohinoorGujarati-Light", "KohinoorGujarati-Bold", "KohinoorGujarati-Regular"]
Family: Kohinoor Telugu Font names: ["KohinoorTelugu-Regular", "KohinoorTelugu-Medium", "KohinoorTelugu-Light"]
Family: Lao Sangam MN Font names: ["LaoSangamMN"]
Family: Malayalam Sangam MN Font names: ["MalayalamSangamMN-Bold", "MalayalamSangamMN"]
Family: Marker Felt Font names: ["MarkerFelt-Thin", "MarkerFelt-Wide"]
Family: Menlo Font names: ["Menlo-BoldItalic", "Menlo-Bold", "Menlo-Italic", "Menlo-Regular"]
Family: Mishafi Font names: ["DiwanMishafi"]
Family: Mukta Mahee Font names: ["MuktaMahee-Light", "MuktaMahee-Bold", "MuktaMahee-Regular"]
Family: Myanmar Sangam MN Font names: ["MyanmarSangamMN", "MyanmarSangamMN-Bold"]
Family: Noteworthy Font names: ["Noteworthy-Bold", "Noteworthy-Light"]
Family: Noto Nastaliq Urdu Font names: ["NotoNastaliqUrdu", "NotoNastaliqUrdu-Bold"]
Family: Noto Sans Kannada Font names: ["NotoSansKannada-Bold", "NotoSansKannada-Light", "NotoSansKannada-Regular"]
Family: Noto Sans Myanmar Font names: ["NotoSansMyanmar-Regular", "NotoSansMyanmar-Bold", "NotoSansMyanmar-Light"]
Family: Noto Sans Oriya Font names: ["NotoSansOriya-Bold", "NotoSansOriya"]
Family: Optima Font names: ["Optima-ExtraBlack", "Optima-BoldItalic", "Optima-Italic", "Optima-Regular", "Optima-Bold"]
Family: Palatino Font names: ["Palatino-Italic", "Palatino-Roman", "Palatino-BoldItalic", "Palatino-Bold"]
Family: Papyrus Font names: ["Papyrus-Condensed", "Papyrus"]
Family: Party LET Font names: ["PartyLetPlain"]
Family: PingFang HK Font names: ["PingFangHK-Medium", "PingFangHK-Thin", "PingFangHK-Regular", "PingFangHK-Ultralight", "PingFangHK-Semibold", "PingFangHK-Light"]
Family: PingFang SC Font names: ["PingFangSC-Medium", "PingFangSC-Semibold", "PingFangSC-Light", "PingFangSC-Ultralight", "PingFangSC-Regular", "PingFangSC-Thin"]
Family: PingFang TC Font names: ["PingFangTC-Regular", "PingFangTC-Thin", "PingFangTC-Medium", "PingFangTC-Semibold", "PingFangTC-Light", "PingFangTC-Ultralight"]
Family: Rockwell Font names: ["Rockwell-Italic", "Rockwell-Regular", "Rockwell-Bold", "Rockwell-BoldItalic"]
Family: Savoye LET Font names: ["SavoyeLetPlain"]
Family: Sinhala Sangam MN Font names: ["SinhalaSangamMN-Bold", "SinhalaSangamMN"]
Family: Snell Roundhand Font names: ["SnellRoundhand", "SnellRoundhand-Bold", "SnellRoundhand-Black"]
Family: Symbol Font names: ["Symbol"]
Family: Tamil Sangam MN Font names: ["TamilSangamMN", "TamilSangamMN-Bold"]
Family: Thonburi Font names: ["Thonburi", "Thonburi-Light", "Thonburi-Bold"]
Family: Times New Roman Font names: ["TimesNewRomanPS-ItalicMT", "TimesNewRomanPS-BoldItalicMT", "TimesNewRomanPS-BoldMT", "TimesNewRomanPSMT"]
Family: Trebuchet MS Font names: ["TrebuchetMS-Bold", "TrebuchetMS-Italic", "Trebuchet-BoldItalic", "TrebuchetMS"]
Family: Verdana Font names: ["Verdana-Italic", "Verdana", "Verdana-Bold", "Verdana-BoldItalic"]
Family: Zapf Dingbats Font names: ["ZapfDingbatsITC"]
Family: Zapfino Font names: ["Zapfino"]

UITextViewをSwiftUIで用いる。

makeUIView, updateUIView, makeCoordinatorを実装する。Coordinatorはdelegateを処理する。

import UIKit
import Combine
// https://www.appcoda.com/swiftui-textview-uiviewrepresentable/
struct EditView: UIViewRepresentable {
    
    @Binding var text: String
    @Binding var textStyle:UIFont.TextStyle
    
    func makeUIView(context: Context) -> UITextView {
        let textView = UITextView()
        
        textView.font = UIFont.preferredFont(forTextStyle: textStyle)
        textView.autocapitalizationType = .sentences
        textView.isSelectable = true
        textView.isUserInteractionEnabled = true
        textView.delegate = context.coordinator
        
        return textView
    }
    
    func updateUIView(_ uiView: UITextView, context: Context) {
        uiView.text = text
        uiView.font = UIFont.preferredFont(forTextStyle: textStyle)
    }
    
    func makeCoordinator() -> Coordinator {
        return Coordinator($text)
    }
}

class Coordinator: NSObject, UITextViewDelegate {
    var text: Binding<String>
    
    init(_ text:Binding<String>) {
        self.text = text
    }
    
    func textViewDidChange(_ textView: UITextView) {
        self.text.wrappedValue = textView.text
    }
    
    func textViewDidBeginEditing(_ textView: UITextView) {
    }
}

使う側は単に呼び出す

struct ContentView: View {
    
    @State private var message = "x"
    @State private var textStyle = UIFont.TextStyle.body
    
    var body: some View {
        VStack(alignment: .leading) {
            Button(action: {
                UIApplication.shared.endEditing()
            }, label: {
                Text("Push")
            })
            Text(message)
            EditView(text: $message, textStyle: $textStyle)
        }
    }
}

今回、キーボードを下げるのは

import UIKit

extension UIApplication {
    func endEditing() {
        sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
    }
}

というextensionを用いた。

簡単な画面遷移

WebからJSON取得して表示するリスト画面と設定画面の遷移をするっでもを作ってみた。 設定画面でwebから取得するかローカルファイルから取得するか選べる。

まずモデルは

public struct Article: Codable {
    let title:String
    let url:String
}

という簡単なもの。

ローカルファイルにはJSON形式で

[{"title":"[pre]【Unity】FungusをLuaで使用する方法","url":"https:\/\/qiita.com\/Humimaro\/items\/76e1730dde5c359f61ba"},...]

のようなものを準備しておく。

ファイルのロードは

import Foundation
import Combine

func load<T:Decodable>(_ filename:String) -> AnyPublisher<T, Error> {
    let data:Data
    
    guard let file = Bundle.main.url(forResource: filename, withExtension: nil)
        else {
            return Fail(error: NSError(domain: "FILE", code: 1, userInfo: nil)).eraseToAnyPublisher()
    }
    
    
    do {
        data = try Data(contentsOf: file)
    } catch  {
        return Fail(error: NSError(domain: "FILE_DATA", code: 1, userInfo: nil)).eraseToAnyPublisher()
    }
    
    do {
        let decoder = JSONDecoder()
        let obj = try decoder.decode(T.self, from: data)
        return Just(obj).setFailureType(to: Error.self).eraseToAnyPublisher()
    } catch {
        return Fail(error: error).eraseToAnyPublisher()
    }
}

のように、Combineを利用してみた。

Webから取得するところは

import Combine
import Foundation

var cancellables = [AnyCancellable]()

// 記事を取得する。
func fetchArticles(isPreview:Bool) -> AnyPublisher<[Article], Error> {
    
    if isPreview {
        return load("articles.json")
    }
    
    let url = URL(string: "https://qiita.com/api/v2/items")!
    let request = URLRequest(url:url)
    
    return URLSession.shared
        .dataTaskPublisher(for: request)
        .tryMap({ (data, response) -> Data in
            guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
                throw URLError(URLError.Code.badServerResponse)
            }
            return data
        })
        .decode(type: [Article].self, decoder: JSONDecoder())
        .eraseToAnyPublisher()
}

のように書き、引数によって、ファイルから取得できるようにしてみた。

ViewModelは

import Foundation
import SwiftUI
import Combine

// 記事ビューモデル
class ViewModel : ObservableObject {
    @Published private(set) var text: String = "Hello, World!"
    @Published private(set) var articles:[Article] = []
    let previewFlg:Bool
    var cancels = [AnyCancellable]()
    
    init(isPreview:Bool = false) {
        previewFlg = isPreview
    }
    
    // タップした
    func onTapped() {
        // 記事を取得する
        fetchArticles(isPreview: previewFlg)
         .receive(on: DispatchQueue.main)
         .sink(receiveCompletion: { result in
            switch result {
            case .failure(let error):
                self.text = error.localizedDescription
            case .finished:
                break
            }
         }) { articles in
            self.articles = articles
         }.store(in: &cancels)
    }
}

のように、タップしたらフェッチしてそれをarticlesに入れるという動きを書いた。

これに対応するContentViewを

import SwiftUI
import Combine


struct ContentView: View {
    @ObservedObject var viewModel:ViewModel
    @EnvironmentObject var settingModel:SettingModel
    var body: some View {
        VStack {
            Toggle(isOn:$settingModel.testMode) {
                Text("")
            }
//            if self.settingModel.testMode {
//                Text("testMode")
//            }
            Button(action: {
                self.viewModel.onTapped()
            }) {
                Text("更新")
            }
            List(viewModel.articles, id: \.title) { article in
                HStack {
                    Text(article.title)
                    Spacer()
                    Button("open") {
                        UIApplication.shared.open(
                            URL(string: article.url)!
                        )
                    }
                }
            }
            Text(viewModel.text)
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView(viewModel: ViewModel(isPreview: true)).environmentObject(SettingModel())
    }
}

のようにした。リスト表示の部分が先程のarticlesの表示をする。 設定からの情報を表示するためにenvironmentObjectも使っている。

設定画面は

import SwiftUI

struct SettingView: View {
    @EnvironmentObject var settingModel:SettingModel

    var body: some View {
        VStack {
            HStack {
                Text("TestMode")
                Spacer()
                Toggle(isOn: $settingModel.testMode) {
                    Text(settingModel.testMode ? "":"")
                }
            }.padding()
        }
    }
}

struct Setting_Previews: PreviewProvider {
    static var previews: some View {
        SettingView().environmentObject(SettingModel())
    }
}

という単純なもの、settingModelはenvironmentObjectでSceneDelegateで注入されていて

import Foundation
import Combine
import SwiftUI

// 設定モデル
class SettingModel : ObservableObject {
    @Published var testMode: Bool = false
}

BaseViewはこの2つをとりまとめていて、

import SwiftUI

struct BaseView: View {
    @State var setting:Bool = false
    var body: some View {
        NavigationView {
            VStack {
                Button("設定") {
                    self.setting = true
                    print("x")
                }
                ContentView(viewModel: ViewModel())
                NavigationLink(destination: SettingView(), isActive: $setting) {
                    EmptyView()
                }
            }
        }.navigationViewStyle(StackNavigationViewStyle())
    }
}

struct BaseView_Previews: PreviewProvider {
    static var previews: some View {
        BaseView().environmentObject(SettingModel())
    }
}

のようにNavigationLinkで設定画面に移動できるようにしている。

これで実装できたが、実際は設定画面の情報をContentのViewModelに渡せていなくて ローカルファイルの読み込みができていない。 そのところは変える必要があるだろう。

簡単なViewModel

ごく簡単にViewModelを作ってみた。

まずモデルは

struct Article: Codable {
    let title:String
    let url:String
}

func fetchArticles() -> AnyPublisher<[Article], Error> {
    let url = URL(string: "https://qiita.com/api/v2/items")!
    let request = URLRequest(url:url)
    
    return URLSession.shared
        .dataTaskPublisher(for: request)
        .map({$0.data})
        .decode(type: [Article].self, decoder: JSONDecoder())
        .eraseToAnyPublisher()
}

ViewModelはこのようにボタンタップをしたらtextが変わる形に書いておく。 ObservableObjectを継承する。

import Foundation
import SwiftUI
import Combine

class ViewModel : ObservableObject {
    @Published private(set) var text: String = "Hello, World!"
    var cancels = [AnyCancellable]()
    func onTapped() {
        fetchArticles()
         .receive(on: DispatchQueue.main)
         .sink(receiveCompletion: {_ in
         }) { articles in
            
             self.text = articles.description
         }.store(in: &cancels)
    }
}

ViewでViewModelをつなげる。@ObservedObjectを使う。

import SwiftUI
import Combine


struct ContentView: View {
    @ObservedObject var viewModel = ViewModel()
    var body: some View {
        VStack {
        Button(action: {
            self.viewModel.onTapped()
        }) {
            Text("Push")
        }
            Text(viewModel.text)
        }
    }
}

ここではエラーについて議論していない。

combineの簡単な例

PassthroughSubject と CurrentValueSubject

を使ってみた。おおよそRxSwift的に使えると思った。

func test() -> Void {
    let pub = PassthroughSubject<Int, Error>()
    _ = pub
        .sink(receiveCompletion: { _ in
            
        }) {
            value in
            print("new val -> \(value)")
    }
    
    pub.send(10)
    pub.send(100)
    pub.send(completion: .finished)
    
}

//test()

func test2() -> Void {
    let pub = CurrentValueSubject<Int, Error>(100)
    _ = pub.sink(receiveCompletion: {_ in
    }, receiveValue: { (value) in
        print("new val -> \(value)")
    })
    
    pub.send(100)
    pub.send(1000)
    pub.send(completion: .finished)
    
}
//test2()

func test3() -> Void {
    let pub = [1,2,3,4,5,6,7,8,9,10].publisher
    _ = pub.sink(receiveValue: { value in
        print("new val -> \(value)")
    })
}
//test3()


func test4() -> Void {
    let pub = [1,2,3,4,5].publisher
    let pub2 = pub.map { (x) -> Int in
        return x*2
    }
    
    _ = pub2.sink(receiveValue: { value in
        print("new val -> \(value)")
    })
}
//test4()


/*
 new val -> (4, 5)
 new val -> (4, 6)
 new val -> (4, 7)
 new val -> (4, 8)
 */
func test5() -> Void {
    let pub = [1,2,3,4].publisher
    let pub2 = [5,6,7,8].publisher
    let pub3 = pub.combineLatest(pub2)
    _ = pub3.sink(receiveValue: { value in
        print("new val -> \(value)")
    })
}

//test5()

/*
 new val -> (1, 5)
 new val -> (2, 6)
 new val -> (3, 7)
 new val -> (4, 8)
 */
func test6() -> Void {
    let pub = [1,2,3,4].publisher
    let pub2 = [5,6,7,8].publisher
    let pub3 = pub.zip(pub2)
    _ = pub3.sink(receiveValue: { value in
        print("new val -> \(value)")
    })
}

//test6()

func test7() -> Void {
    let sub = PassthroughSubject<Int, Error>()
    _ = sub.sink(receiveCompletion: { comp in
        print("\(comp)")
    }, receiveValue: { (value) in
        print("new val -> \(value)")
    })
    
    sub.send(100)
    sub.send(120)
    sub.send(completion: .failure(NSError(domain: "Error!", code: 0, userInfo: nil)))
}

test7()

ファイル読み込みで一行毎に検索

import sys

# 該当言語
searchWord = "hoge"

# ファイル読み込み
text = open(sys.argv[1], "r")
# 一行づつ読み出し
for line in text:
    # 該当単語があるなら書き出し
    if searchWord in line:
        print(line)

# 閉じる
text.close()

Combine

細かいことは無視してひとまず動かしてみる例を勉強してみた。

JSONを読み込む用のモデル

struct Article: Codable {
    let title:String
    let url:String
}

読み込む

func fetchArticles() -> AnyPublisher<[Article], Error> {
    let url = URL(string: "https://qiita.com/api/v2/items")!
    let request = URLRequest(url:url)
    
    return URLSession.shared
        .dataTaskPublisher(for: request)
        .map({$0.data})
        .decode(type: [Article].self, decoder: JSONDecoder())
        .eraseToAnyPublisher()
}

無理やり表示させてみる。

import SwiftUI
import Combine

var cancels = [AnyCancellable]()

struct ContentView: View {
    @State var tex = "x"
    var body: some View {
        VStack {
        Button(action: {
           fetchArticles()
            .sink(receiveCompletion: {_ in
            }) { articles in
                self.tex = articles.description
            }.store(in: &cancels)
        }) {
            Text("a")
        }
            Text(tex)
        }
    }
}

これでひとまず動かせた。 AnyPublisherがRxSwiftのObject的なもの sinkで購読、storeでキャンセル待ちを保存しておく。 実際の実装ではViewでfetch等はしないだろう。

UIKitとの連携

チュートリアルより。 UIPageViewControllerをSwiftUIで使う。

まず、使いたいUIViewControllerをwrapするクラスを作る。 そのクラスはUIViewControllerRepresentableを実装する。

import Foundation
import SwiftUI
import UIKit

struct PageViewController: UIViewControllerRepresentable {
    
    typealias UIViewControllerType = UIPageViewController
    
    var controllers:[UIViewController]

    // ページ情報
    @Binding var currentPage:Int
    
  // 関連するdataSorce, delegate等のクラスはCoordinaterとして渡す。
    func makeCoordinator() -> Coordinater {
        Coordinater(self)
    }
    
 // ここでViewControllerを作って渡す。delegate等もcontextを通じてつなげる。
    func makeUIViewController(context contetxt:Context) -> UIPageViewController {
        let pageViewController = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .
            horizontal)
        pageViewController.dataSource = contetxt.coordinator
        pageViewController.delegate = contetxt.coordinator
        return pageViewController
    }
    
// 更新
    func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
        pageViewController.setViewControllers([controllers[currentPage]], direction: .forward, animated: true)
    }
    
// DataSorce, Delegate等はCoordinaterとして宣言する。
    class Coordinater: NSObject, UIPageViewControllerDataSource,UIPageViewControllerDelegate {
        var parent:PageViewController
        init(_ pageViewController:PageViewController) {
            self.parent = pageViewController
        }
        
        func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
            guard let index = parent.controllers.firstIndex(of: viewController) else {
                return nil
            }
            if index == 0 {
                return parent.controllers.last;
            }
            return parent.controllers[index - 1]
        }
        
        func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
            guard let index = parent.controllers.firstIndex(of: viewController) else {
                           return nil
                       }
                       if index + 1 == parent.controllers.count {
                           return parent.controllers.first
                       }
                       return parent.controllers[index + 1]
        }
        
        func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
            if completed, let visibleViewController = pageViewController.viewControllers?.first,
                let inedx = parent.controllers.firstIndex(of:visibleViewController) {
                parent.currentPage = inedx
            }
        }
        
        
    }
}

UIViewも似た感じで扱える。UIViewRepresentableを実装する。

import SwiftUI
import UIKit

struct PageControl: UIViewRepresentable {
    var numberOfPages:Int
    @Binding var currentPage:Int
    
    func makeUIView(context:Context) -> UIPageControl {
        let control = UIPageControl()
        control.numberOfPages = numberOfPages
       // controlのtarget, actionをつないでいる。
        control.addTarget(context.coordinator, action: #selector(Coordinator.updateCurrentPage(sender:)), for:  .valueChanged)
        return control
    }
    
    // 表示ロジック用のクラスを作る。
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
    
  // 更新
    func updateUIView(_ uiView: UIPageControl, context: UIViewRepresentableContext<PageControl>) {
        uiView.currentPage = currentPage
    }
    
    class Coordinator: NSObject {
        var control: PageControl
        
        init(_ control:PageControl) {
            self.control = control
        }
        
        @objc func updateCurrentPage(sender:UIPageControl) {
            control.currentPage = sender.currentPage
        }
    }

}

これらのプロトコルを実装すれば普通のビューのように扱える。

import SwiftUI

struct PageView<Page:View>: View {
/// UIPageViewControllerではUIViewController達として扱うのでSwiftUIのViewをUIHostViewControllerでくるんで扱う。
    var viewControllers:[UIHostingController<Page>]
    @State var currentPage = 0
    init(_ views:[Page])  {
        self.viewControllers = views.map {
            UIHostingController(rootView: $0)
        }
    }
    
    var body: some View {
            ZStack(alignment: .bottomTrailing){
                PageViewController(controllers: viewControllers, currentPage: $currentPage)
                PageControl(numberOfPages: viewControllers.count, currentPage: $currentPage)
                    .padding(.trailing)
            }
    }
}

struct PageView_Previews: PreviewProvider {
    static var previews: some View {
        PageView(features.map { FeatureCard(landmark: $0)})
            .aspectRatio(3/2, contentMode: .fit)
    }
}

編集画面をつくる

チュートリアルより

まずプロフィールのデータ構造は

struct Profile {
    var username: String
    var prefersNotifications: Bool
    var seasonalPhoto: Season
    var goalDate: Date
    
// デフォルト設定
    static let `default` = Self(username: "g_kumar", prefersNotifications: true, seasonalPhoto: .winter)
    
    init(username: String, prefersNotifications: Bool = true, seasonalPhoto: Season = .winter) {
        self.username = username
        self.prefersNotifications = prefersNotifications
        self.seasonalPhoto = seasonalPhoto
        self.goalDate = Date()
    }
  
// enumにCaseIterableつけとく
    enum Season: String, CaseIterable {
        case spring = "🌷"
        case summer = "🌞"
        case autumn = "🍂"
        case winter = "☃️"
    }
}

編集画面にある日付Pickerの日付指定の範囲の作成が面白い

    var dateRange:ClosedRange<Date> {
        let min = Calendar.current.date(byAdding: .year, value: -1, to: profile.goalDate)!
        let max = Calendar.current.date(byAdding: .year, value: 1, to: profile.goalDate)!
        return min...max
    }

編集画面は

import SwiftUI

struct ProfileEditor: View {

// 双方向バインディング
    @Binding var profile:Profile
    
    var dateRange:ClosedRange<Date> {
        let min = Calendar.current.date(byAdding: .year, value: -1, to: profile.goalDate)!
        let max = Calendar.current.date(byAdding: .year, value: 1, to: profile.goalDate)!
        return min...max
    }
    
    var body: some View {
        List {
            HStack {
                Text("UserName").bold()
                Divider()
// ユーザープロファイル
                TextField("Username", text: $profile.username)
            }
            Toggle(isOn:$profile.prefersNotifications) {Text("Enable Notifications")}
            
            
            VStack(alignment: .leading, spacing: 20) {
                Text("Seasonal Photo").bold()
// ピッカー、季節を選ぶ。セグメント
                Picker("Seasonal Photo", selection: $profile.seasonalPhoto) {
                    ForEach(Profile.Season.allCases, id: \.self) {
                        season in
                        Text(season.rawValue ).tag(season)}
                }.pickerStyle(SegmentedPickerStyle())
                
            }
            .padding(.top)
// 日付ピッカー
            VStack(alignment: .leading, spacing: 20) {
                Text("Goal Date").bold()
                DatePicker(
                    "Goal Date",
                    selection: $profile.goalDate,
                    in: dateRange,
                    displayedComponents: .date
                )
            }
            .padding(.top)
        }
    }
}

struct ProfileEditor_Previews: PreviewProvider {
    static var previews: some View {
        ProfileEditor(profile: .constant(.default))
    }
}

プロフィール画面の全体は

import SwiftUI

struct ProfileHost: View {
/// editModeはEnvironmentValuesでもともとある環境変数で、エディットモードであるかないか
    @Environment(\.editMode) var mode
/// 環境変数、.environmentObject(UserData())で渡される。
    @EnvironmentObject var userData: UserData
    @State var draftProfile = Profile.default
    var body: some View {
        VStack(alignment: .leading,
               spacing: 20) {
                HStack {
/// キャンセルボタンは編集モードのときのみ出る
                    if self.mode?.wrappedValue == .active {
                        Button("Cancel") {
/// キャンセルするとドラフトはユーザープロフィールに戻る
                            self.draftProfile = self.userData.profile
                            self.mode?.animation().wrappedValue = .inactive
                        }
                    }
                    Spacer()
                    EditButton()
                }
/// 編集画面かそうでないかの出し分けをする
                if self.mode?.wrappedValue  == .inactive {
                    ProfileSummary(profile: draftProfile)// 表示はドラフトを使う
                } else {
                    ProfileEditor(profile: $draftProfile)// 表示はドラフトを使う
                        .onAppear {
/// 編集画面が表示されるとき、ユーザープロフィールをドラフトにする
                            self.draftProfile = self.userData.profile
                    }
                    .onDisappear {
/// 編集画面が消える時にドラフトをユーザーのプロフィールにする
                        self.userData.profile = self.draftProfile
                    }
                }
               
        }
        .padding()
    }
}

struct ProfileHost_Previews: PreviewProvider {
    static var previews: some View {
        ProfileHost().environmentObject(UserData())
    }
}

画面遷移をつける

チュートリアルより ホーム画面から遷移するコードを書く。

struct CategoryHome: View {
    
   // リストをcategory名をキーに辞書化している。
    var categories: [String :[Landmark]] {
        Dictionary (
            grouping: landmarkData,
            by: { $0.category.rawValue }
        )
    }
    
     //  お気に入りリスト
    var featuered : [Landmark] {
        landmarkData.filter {
            $0.isFeatured
        }
    }
    
    @State var showingProfile = false
    
   // プロフィールボタン
    var profileButton: some View {
        Button(action: { self.showingProfile.toggle() }) {
            Image(systemName: "person.crop.circle")
                .imageScale(.large)
                .accessibility(label: Text("User Profile"))
                .padding()
        }
    }
    
    var body: some View {
        // ここで囲まれている部分が画面遷移する。
        NavigationView {
            List {
// お気に入りは大きめにする。
                FeaturedLandmarks(landmarks: featuered)
                    .scaledToFill()
                    .frame(height:200)
                    .clipped()
                    .listRowInsets(EdgeInsets())
// 各カテゴリーごとに縦にリスト
                ForEach(categories.keys.sorted(), id: \.self) {key in
                    CategoryRow(categoryName: key, items: self.categories[key]!)
                    
                }
                .listRowInsets(EdgeInsets()) // 画面の左右の余白の削除
                
              // 画面遷移
                NavigationLink(destination: LandmarkList()) {
                    Text("See All")
                }
            }
            .navigationBarTitle("Featured")
            .navigationBarItems(trailing: profileButton) // プロフィールボタンをナビバーの右側に配置
            .sheet(isPresented: $showingProfile) { Text("User Profile") // プロフィール画面をシートで出す
            }
        }
    }
}
// お気にいりの画像はリサイズ
struct FeaturedLandmarks: View {
    var landmarks: [Landmark]
    var body: some View {
        landmarks[0].image.resizable()
    }
}

struct CategoryHome_Previews: PreviewProvider {
    static var previews: some View {
        CategoryHome()
    }
}
struct CategoryRow: View {
    var categoryName: String
    var items: [Landmark]
    
    var body: some View {
        VStack {
// カテゴリ名
            Text(self.categoryName)
                .font(.headline)
                .padding(.leading, 15)
                .padding(.top, 5)
// 横スクロール
            ScrollView(.horizontal, showsIndicators: false) {
                HStack(alignment: .top, spacing: 0.0 ) {
                    ForEach(self.items) {
                        landmark in
// 詳細画面へのリンク
                        NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
                            CategoryItem(landmark:landmark)
                        }
                    }
                }
            }
            .frame(height:185)
        }
    }
}

struct CategoryItem: View {
    var landmark: Landmark
    var body: some View {
        VStack(alignment: .leading) {
            landmark.image
                .renderingMode(.original)
                .resizable()
                .frame(width: 155, height: 155)
                .cornerRadius(5)
            Text(landmark.name)
                .foregroundColor(.primary)
                .font(.caption)
            
        }
        .padding(.leading, 15)
    }
}

struct CategoryRow_Previews: PreviewProvider {
    static var previews: some View {
        CategoryRow(categoryName: landmarkData[0].category.rawValue, items: Array(landmarkData.prefix(3))
        )
    }
}

アニメーションをつける

チュートリアルより アニメーションは.animationで簡単につけられる。 .animation(nil)でアニメーションを切ることができる。 withAnimationを使うと、ブロック内にアニメーションがつけられる。 遷移時の遷移方法ははtransisionで指定できる。 transisionはAnyTransitionとして自作できる。 例えば表示されるときと消えるときで次のように定義できる。

extension AnyTransition {
    static var moveAndFade: AnyTransition {
        let insertion = AnyTransition.move(edge: .trailing)
        let removal = AnyTransition.scale
            .combined(with: .opacity)
        return asymmetric(insertion: insertion, removal: removal)
    }
}

チュートリアルではボタンを押すとグラフがあらわれ、グラフが波打つ。

まず、グラフにつかうデータモデル

struct Hike: Codable, Hashable, Identifiable {
    var name: String
    var id: Int
    var distance: Double
    var difficulty: Int
    var observations: [Observation]

    static var formatter = LengthFormatter()
    
    var distanceText: String {
        return Hike.formatter
            .string(fromValue: distance, unit: .kilometer)
    }

    struct Observation: Codable, Hashable {
        var distanceFromStart: Double
        
        var elevation: Range<Double>
        var pace: Range<Double>
        var heartRate: Range<Double>
    }
}

そしてグラフの描画部分

func rangeOfRanges<C: Collection>(_ ranges: C) -> Range<Double>
    where C.Element == Range<Double> {
    guard !ranges.isEmpty else { return 0..<0 }
    let low = ranges.lazy.map { $0.lowerBound }.min()!
    let high = ranges.lazy.map { $0.upperBound }.max()!
    return low..<high
}

func magnitude(of range: Range<Double>) -> Double {
    return range.upperBound - range.lowerBound
}

// 波打つアニメーションrippleを定義、これはスプリングを減衰率を指定し、ゆっくり、また値に応じたdelayをつけるという工夫をしている。
extension Animation {
    static func ripple(index:Int) -> Animation {
        Animation.spring( dampingFraction: 0.5)
        .speed(2)
            .delay(0.03 * Double(index))
    }
}

struct HikeGraph: View {
    var hike: Hike
    var path: KeyPath<Hike.Observation, Range<Double>>
    
    var color: Color {
        switch path {
        case \.elevation:
            return .gray
        case \.heartRate:
            return Color(hue: 0, saturation: 0.5, brightness: 0.7)
        case \.pace:
            return Color(hue: 0.7, saturation: 0.4, brightness: 0.7)
        default:
            return .black
        }
    }
    
    var body: some View {
        let data = hike.observations
        let overallRange = rangeOfRanges(data.lazy.map { $0[keyPath: self.path] })
        let maxMagnitude = data.map { magnitude(of: $0[keyPath: path]) }.max()!
        let heightRatio = (1 - CGFloat(maxMagnitude / magnitude(of: overallRange))) / 2

        return GeometryReader { proxy in
            HStack(alignment: .bottom, spacing: proxy.size.width / 120) {
                ForEach(data.indices) { index in
                    GraphCapsule(
                        index: index,
                        height: proxy.size.height,
                        range: data[index][keyPath: self.path],
                        overallRange: overallRange)
                    .colorMultiply(self.color)
                        .transition(.slide) // スライド
                        .animation(.ripple(index: index)) // ここで波打つ
                }
                .offset(x: 0, y: proxy.size.height * heightRatio)
            }
        }
    }
}

GraphCapsuleというのはひとまず、無視するが一応

struct GraphCapsule: View {
    var index: Int
    var height: CGFloat
    var range: Range<Double>
    var overallRange: Range<Double>
    
    var heightRatio: CGFloat {
        max(CGFloat(magnitude(of: range) / magnitude(of: overallRange)), 0.15)
    }
    
    var offsetRatio: CGFloat {
        CGFloat((range.lowerBound - overallRange.lowerBound) / magnitude(of: overallRange))
    }
    
    var body: some View {
        Capsule()
            .fill(Color.white)
            .frame(height: height * heightRatio)
            .offset(x: 0, y: height * -offsetRatio)
    }
}

struct GraphCapsule_Previews: PreviewProvider {
    static var previews: some View {
        GraphCapsule(index: 0, height: 150, range: 10..<50, overallRange: 0..<100)
    }
}

グラフを表示する詳細画面 dataToShowを動的に変える。

struct HikeDetail: View {
    let hike: Hike
    @State var dataToShow = \Hike.Observation.elevation
    
    var buttons = [
        ("Elevation", \Hike.Observation.elevation),
        ("Heart Rate", \Hike.Observation.heartRate),
        ("Pace", \Hike.Observation.pace),
    ]
    
    var body: some View {
        return VStack {
            HikeGraph(hike: hike, path: dataToShow)
                .frame(height: 200)
            
            HStack(spacing: 25) {
                ForEach(buttons, id: \.0) { value in
                    Button(action: {
                        self.dataToShow = value.1
                    }) {
                        Text(value.0)
                            .font(.system(size: 15))
                            .foregroundColor(value.1 == self.dataToShow
                                ? Color.gray
                                : Color.accentColor)
                            .animation(nil)
                    }
                }
            }
        }
    }
}

項目からタップしてグラフを出すようにする。 transisionを定義して使っている

extension AnyTransition {
    static var moveAndFade: AnyTransition {
        let insertion = AnyTransition.move(edge: .trailing)
        let removal = AnyTransition.scale
            .combined(with: .opacity)
        return asymmetric(insertion: insertion, removal: removal)
    }
}

struct HikeView: View {
    var hike: Hike
    @State private var showDetail = true
    
    var body: some View {
        VStack {
            HStack {
                HikeGraph(hike: hike, path: \.elevation)
                    .frame(width: 50, height: 30)
                    .animation(nil)
                
                VStack(alignment: .leading) {
                    Text(hike.name)
                        .font(.headline)
                    Text(hike.distanceText)
                }
                
                Spacer()

                Button(action: {
                    withAnimation {
                        self.showDetail.toggle()
                    }
                }) {
                    Image(systemName: "chevron.right.circle")
                        .imageScale(.large)
                        .rotationEffect(.degrees(showDetail ? 90 : 0))
                        .scaleEffect(showDetail ? 1.5:1.0)
                        .padding()
                }
            }

            if showDetail {
                HikeDetail(hike: hike)
                    .transition(.moveAndFade)
            }
        }
    }
}

アイコンを作る

チュートリアルより。 背景に6角形を描き、その上に山のシンボルを角度をかえてぐるっと8個放射状に配置する。ZStackを使って重ねている。 放射状に配置するのはForEachで、サイズをハードコーディングを避けるにはGeometryReaderを用いている。 ポイントは様々なビューを作って組み合わせているところ。

まず、背景の6角形

struct BadgeBackground: View {
    var body: some View {
        GeometryReader { geometry in
            Path {
                path in
                var width:CGFloat = min(geometry.size.width, geometry.size.height)
                let height = width
                let xScale: CGFloat = 0.832
                let xOffset = (width * (1.0 - xScale)) / 2.0
                width *= xScale
                path.move(to: CGPoint(x: xOffset +  width * 0.95, y: height * (0.20 + HexagonParameters.adjustment)))
                
                HexagonParameters.points.forEach {
                    path.addLine(to:
                        .init(x:xOffset + width * $0.useWidth.0 * $0.xFactors.0,
                              y:height * $0.useHeight.0 * $0.yFactors.0))
                    path.addQuadCurve(to:
                        .init(
                            x: xOffset + width * $0.useWidth.1 * $0.xFactors.1,
                            y: height * $0.useHeight.1 * $0.yFactors.1), control:
                        .init(
                            x:xOffset + width * $0.useWidth.2 * $0.xFactors.2,
                            y:height * $0.useHeight.2 * $0.yFactors.2))
                }
            }
            .fill(LinearGradient(gradient: .init(colors: [Self.gradientStart, Self.gradientEnd]), startPoint: .init(x: 0.5, y: 0), endPoint: .init(x: 0.5, y: 0.6)))
            .aspectRatio(1, contentMode: .fit)
        }
    }
    static let gradientStart = Color(red: 239.0 / 255, green: 120.0 / 255, blue: 221.0 / 255)
    static let gradientEnd = Color(red: 239.0 / 255, green: 172.0 / 255, blue: 120.0 / 255)
}

次に山のシンボル

struct BadgeSymbol: View {
    static let symbolColor = Color(red: 79.0 / 255, green: 79.0 / 255, blue: 191.0 / 255)

    var body: some View {
        
        GeometryReader { geometry in
            Path { path in
                let width = min(geometry.size.width, geometry.size.height)
                let height = width * 0.75
                let spacing = width * 0.030
                let middle = width / 2
                let topWidth = 0.226 * width
                let topHeight = 0.488 * height
                
                path.addLines([
                    CGPoint(x: middle, y: spacing),
                    CGPoint(x: middle - topWidth, y: topHeight - spacing),
                    CGPoint(x: middle, y: topHeight / 2 + spacing),
                    CGPoint(x: middle + topWidth, y: topHeight - spacing),
                    CGPoint(x: middle, y: spacing)
                ])
                
                path.move(to: CGPoint(x: middle, y: topHeight / 2 + spacing * 3))
                path.addLines([
                    CGPoint(x: middle - topWidth, y: topHeight + spacing),
                    CGPoint(x: spacing, y: height - spacing),
                    CGPoint(x: width - spacing, y: height - spacing),
                    CGPoint(x: middle + topWidth, y: topHeight + spacing),
                    CGPoint(x: middle, y: topHeight / 2 + spacing * 3)
                ])
            }
            .fill(Self.symbolColor)
        }
    }
}

山を回転させるのに一つビューを作っている。

struct RotatedBadgeSymbol: View {
    let angle:Angle
    
    var body: some View {
        BadgeSymbol()
        .padding(-60)
            .rotationEffect(angle, anchor: .bottom)
    }
}

最後にzstackで組み合わせる。 全体サイズをGeometryReaderで調整し、バッジのサイズはscaleEffectで調整している点に注意。 ForEachを用いてる点も。

struct Badge: View {
    static let rotationCount = 8
    
    var badgesymbols : some View {
        ForEach(0..<Badge.rotationCount) {
            i in
            RotatedBadgeSymbol(angle: .init(degrees: Double(i)/Double(Badge.rotationCount) * 360.0))
        }
        .opacity(0.5)
    }
    var body: some View {
        ZStack {
            BadgeBackground()
            GeometryReader {
                    geometry in
                self.badgesymbols
                    .scaleEffect(1.0/4.0, anchor: .top)
                    .position(x:geometry.size.width / 2.0,y:(3.0/4.0) * geometry.size.height)
            }
        }
    }
}

パスで描く

チュートリアルより Pathで使う描画データ

struct HexagonParameters {
    struct Segment {
        let useWidth: (CGFloat, CGFloat, CGFloat)
        let xFactors: (CGFloat, CGFloat, CGFloat)
        let useHeight: (CGFloat, CGFloat, CGFloat)
        let yFactors: (CGFloat, CGFloat, CGFloat)
    }
    
    static let adjustment: CGFloat = 0.085
    static let points = [
        Segment(
            useWidth:  (1.00, 1.00, 1.00),
            xFactors:  (0.60, 0.40, 0.50),
            useHeight: (1.00, 1.00, 0.00),
            yFactors:  (0.05, 0.05, 0.00)
        ),
        Segment(
            useWidth:  (1.00, 1.00, 0.00),
            xFactors:  (0.05, 0.00, 0.00),
            useHeight: (1.00, 1.00, 1.00),
            yFactors:  (0.20 + adjustment, 0.30 + adjustment, 0.25 + adjustment)
        ),
        Segment(
            useWidth:  (1.00, 1.00, 0.00),
            xFactors:  (0.00, 0.05, 0.00),
            useHeight: (1.00, 1.00, 1.00),
            yFactors:  (0.70 - adjustment, 0.80 - adjustment, 0.75 - adjustment)
        ),
        Segment(
            useWidth:  (1.00, 1.00, 1.00),
            xFactors:  (0.40, 0.60, 0.50),
            useHeight: (1.00, 1.00, 1.00),
            yFactors:  (0.95, 0.95, 1.00)
        ),
        Segment(
            useWidth:  (1.00, 1.00, 1.00),
            xFactors:  (0.95, 1.00, 1.00),
            useHeight: (1.00, 1.00, 1.00),
            yFactors:  (0.80 - adjustment, 0.70 - adjustment, 0.75 - adjustment)
        ),
        Segment(
            useWidth:  (1.00, 1.00, 1.00),
            xFactors:  (1.00, 0.95, 1.00),
            useHeight: (1.00, 1.00, 1.00),
            yFactors:  (0.30 + adjustment, 0.20 + adjustment, 0.25 + adjustment)
        )
    ]
}

ビュー

struct Badge: View {
    var body: some View {
// GeometryReaderでビューのサイズを動的に取っている
        GeometryReader { geometry in
// Pathの開始
            Path {
                path in
                var width:CGFloat = min(geometry.size.width, geometry.size.height)
                let height = width
                let xScale: CGFloat = 0.832
                let xOffset = (width * (1.0 - xScale)) / 2.0
                width *= xScale
// 始点にmoveする
                path.move(to: CGPoint(x: xOffset +  width * 0.95, y: height * (0.20 + HexagonParameters.adjustment)))

// ここから描画データ通りに線をおく。                
                HexagonParameters.points.forEach {
                    path.addLine(to:
                        .init(x:xOffset + width * $0.useWidth.0 * $0.xFactors.0,
                              y:height * $0.useHeight.0 * $0.yFactors.0))
// 描画データには曲線の情報もある
                    path.addQuadCurve(to:
                        .init(
                            x: xOffset + width * $0.useWidth.1 * $0.xFactors.1,
                            y: height * $0.useHeight.1 * $0.yFactors.1), control:
                        .init(
                            x:xOffset + width * $0.useWidth.2 * $0.xFactors.2,
                            y:height * $0.useHeight.2 * $0.yFactors.2))
                }
            }// ここで描画、グラデーションをつけた。
            .fill(LinearGradient(gradient: .init(colors: [Self.gradientStart, Self.gradientEnd]), startPoint: .init(x: 0.5, y: 0), endPoint: .init(x: 0.5, y: 0.6)))
            .aspectRatio(1, contentMode: .fit)
        }
    }
    static let gradientStart = Color(red: 239.0 / 255, green: 120.0 / 255, blue: 221.0 / 255)
    static let gradientEnd = Color(red: 239.0 / 255, green: 172.0 / 255, blue: 120.0 / 255)
}

ボタンのタップや表示のフィルター

チュートリアルより。 まず、セルにスターの表示非表示をつける。

struct LandmarkRow: View {
    var landmark: Landmark
    
    var body: some View {
        HStack {
            landmark.image
                .resizable()
                .frame(width: 50, height: 50)
            Text(landmark.name)
            Spacer()

            if  landmark.isFavorite  {
                Image(systemName: "star.fill")
                    .imageScale(.medium)
                    .foregroundColor(.yellow)
            }
        }
    }
}

これはif文で簡単につけられる。

次にリストでお気に入りだけを表示するようにトグルをつける。

struct LandmarkList: View {
    @EnvironmentObject private var userData: UserData
    
    var body: some View {
        NavigationView {
            List {
                Toggle(isOn: $userData.showFavoritesOnly) {
                    Text("Show Favorites Only")
                }
                
                ForEach(userData.landmarks) { landmark in
                    if !self.userData.showFavoritesOnly || landmark.isFavorite {
                        NavigationLink(
                            destination: LandmarkDetail(landmark: landmark)
                                .environmentObject(self.userData)
                        ) {
                            LandmarkRow(landmark: landmark)
                        }
                    }
                }
            }
            .navigationBarTitle(Text("Landmarks"))
        }
    }
}

@EnvironmentObjectに指定しているUserDataはその属性を変化させると表示が更新される。 $userData.showFavoritesOnlyはToggle(isOn:)にバインドさせている。

詳細画面ではスターを付けられるようにしている。

struct LandmarkDetail: View {
    @EnvironmentObject var userData: UserData
    var landmark: Landmark
    
    var landmarkIndex: Int {
        userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
    }
    
    var body: some View {
        VStack {
            MapView(coordinate: landmark.locationCoordinate)
                .edgesIgnoringSafeArea(.top)
                .frame(height: 300)
            
            CircleImage(image: landmark.image)
                .offset(x: 0, y: -130)
                .padding(.bottom, -130)
            
            VStack(alignment: .leading) {
                HStack {
                    Text(landmark.name)
                        .font(.title)
                    
                    Button(action: {
                        self.userData.landmarks[self.landmarkIndex]
                            .isFavorite.toggle()
                    }) {
                        if self.userData.landmarks[self.landmarkIndex]
                            .isFavorite {
                            Image(systemName: "star.fill")
                                .foregroundColor(Color.yellow)
                        } else {
                            Image(systemName: "star")
                                .foregroundColor(Color.gray)
                        }
                    }
                }
                
                HStack(alignment: .top) {
                    Text(landmark.park)
                        .font(.subheadline)
                    Spacer()
                    Text(landmark.state)
                        .font(.subheadline)
                }
            }
            .padding()
            
            Spacer()
        }
    }
}

struct LandmarkDetail_Previews: PreviewProvider {
    static var previews: some View {
        let userData = UserData()
        return LandmarkDetail(landmark: userData.landmarks[0])
            .environmentObject(userData)
    }
}

最後にUserDataは

import Combine
import SwiftUI

final class UserData: ObservableObject {
    @Published var showFavoritesOnly = false
    @Published var landmarks = landmarkData
}

であって、@PublishedをつけたObservableObjectであることがポイント。

リスト表示

チュートリアルより

まず静的なJSONのデータ

[
    {
        "name": "Turtle Rock",
        "category": "Featured",
        "city": "Twentynine Palms",
        "state": "California",
        "id": 1001,
        "park": "Joshua Tree National Park",
        "coordinates": {
            "longitude": -116.166868,
            "latitude": 34.011286
        },
        "imageName": "turtlerock"
    },
{...}
]

がある。これをモデルに読み込む。モデルは

import SwiftUI
import CoreLocation

struct Landmark: Hashable, Codable, Identifiable {
    var id: Int
    var name: String
    fileprivate var imageName: String
    fileprivate var coordinates: Coordinates
    var state: String
    var park: String
    var category: Category

    var locationCoordinate: CLLocationCoordinate2D {
        CLLocationCoordinate2D(
            latitude: coordinates.latitude,
            longitude: coordinates.longitude)
    }

    enum Category: String, CaseIterable, Codable, Hashable {
        case featured = "Featured"
        case lakes = "Lakes"
        case rivers = "Rivers"
    }
}

extension Landmark {
    var image: Image {
        ImageStore.shared.image(name: imageName)
    }
}

struct Coordinates: Hashable, Codable {
    var latitude: Double
    var longitude: Double
}

でCodableに対応しているのでそのまま読み込める。 読み込むところはJSONDecoderというのを用いる。

import UIKit
import SwiftUI
import CoreLocation

let landmarkData: [Landmark] = load("landmarkData.json")

func load<T: Decodable>(_ filename: String) -> T {
    let data: Data
    
    guard let file = Bundle.main.url(forResource: filename, withExtension: nil)
        else {
            fatalError("Couldn't find \(filename) in main bundle.")
    }
    
    do {
        data = try Data(contentsOf: file)
    } catch {
        fatalError("Couldn't load \(filename) from main bundle:\n\(error)")
    }
    
    do {
        let decoder = JSONDecoder()
        return try decoder.decode(T.self, from: data)
    } catch {
        fatalError("Couldn't parse \(filename) as \(T.self):\n\(error)")
    }
}

final class ImageStore {
    typealias _ImageDictionary = [String: CGImage]
    fileprivate var images: _ImageDictionary = [:]

    fileprivate static var scale = 2
    
    static var shared = ImageStore()
    
    func image(name: String) -> Image {
        let index = _guaranteeImage(name: name)
        
        return Image(images.values[index], scale: CGFloat(ImageStore.scale), label: Text(name))
    }

    static func loadImage(name: String) -> CGImage {
        guard
            let url = Bundle.main.url(forResource: name, withExtension: "jpg"),
            let imageSource = CGImageSourceCreateWithURL(url as NSURL, nil),
            let image = CGImageSourceCreateImageAtIndex(imageSource, 0, nil)
        else {
            fatalError("Couldn't load image \(name).jpg from main bundle.")
        }
        return image
    }
    
    fileprivate func _guaranteeImage(name: String) -> _ImageDictionary.Index {
        if let index = images.index(forKey: name) { return index }
        
        images[name] = ImageStore.loadImage(name: name)
        return images.index(forKey: name)!
    }
}

リストに表示するセルに対応するものは

import SwiftUI

struct LandmarkRow: View {
    var landmark: Landmark
    var body: some View {
        HStack {
            landmark.image
                .resizable()
                .frame(width: 50, height: 50)
            Text(landmark.name)
            Spacer()
        }
    }
}

struct LandmarkRow_Previews: PreviewProvider {
    static var previews: some View {
        Group {
            LandmarkRow(landmark: landmarkData[0])
            LandmarkRow(landmark: landmarkData[1])
        }
        .previewLayout(.fixed(width: 300, height: 70))

    }
}

のように簡単につくってプレビューもみることができる。 プレビューでは2つのセルを表示していてGroupでまとめてある。 これをリストに表示する。

import SwiftUI

struct LandmarkList: View {
    var body: some View {
        NavigationView {
            List(landmarkData) {landmark in
                NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
                    LandmarkRow(landmark:landmark)
                }
            }
            .navigationBarTitle(Text("Landmarks"))
        }
    }
}

struct LandmarkList_Previews: PreviewProvider {
    static var previews: some View {
        ForEach(["iPhone SE","iPhone XS Max"], id: \.self) {
            deviceName in
            LandmarkList()
            .previewDevice(PreviewDevice(rawValue: deviceName))
            .previewDisplayName(deviceName)
        }
    }
}

ここで、プレビューを色々なデバイスで同時にプレビューできるようにしている。 また、NavigationLinkに詳細画面に飛ばしている。その際にデータを渡している点に注目。

詳細画面は

import SwiftUI

struct LandmarkDetail: View {
    
    var landmark:Landmark

    var body: some View {
        VStack {
            MapView(coordinate: landmark.locationCoordinate)
                .edgesIgnoringSafeArea(.top)
                .frame(height: 300)

            CircleImage(image: landmark.image)
                .offset(x: 0, y: -130)
                .padding(.bottom, -130)

            VStack(alignment: .leading) {
                Text(landmark.name)
                    .font(.title)
                HStack(alignment: .top) {
                    Text(landmark.park)
                        .font(.subheadline)
                    Spacer()
                    Text(landmark.state)
                        .font(.subheadline)
                }
            }
            .padding()

            Spacer()
        }
        .navigationBarTitle(Text(landmark.name), displayMode: .inline)
    }
}

struct LandmarkDetail_Previews: PreviewProvider {
    static var previews: some View {
        LandmarkDetail(landmark:landmarkData[0])
    }
}