Rodhos Soft

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

編集画面をつくる

チュートリアルより

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

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])
    }
}

Viewの配置例

import SwiftUI

struct ContentView: View {
    @State var tapped = false
    
    var tap: some Gesture {
        TapGesture(count: 1)
        .onEnded({ _ in
            self.tapped = !self.tapped;
        })
    }
    
    var circle : some View {
        Circle()
            .fill(self.tapped ? Color.red : Color.black)
            .frame(width: 100, height: 100, alignment: .center)    }
    
    var titleText : some View {
        Text("Hello, World")
            .font(.title)
            .foregroundColor(.green)
        .gesture(tap)
    }
    
    var body: some View {
        VStack {
            MapView()
                .edgesIgnoringSafeArea(.top)  // ステータス部分まで描画
                .frame(height:300) // MapViewは横は自動で拡がる。
            CircleImage()
                .offset(y:-130)  // ここで地図にめり込ませて
                .padding(.bottom, -130) // めり込んだ分を引き上げる
            VStack {
                titleText;
                HStack {
                    Text("Joshua Tree National Park")
                        .font(.headline)
                    Spacer()
                    Text("California")
                        .font(.headline)
                }
                circle;
            }
            .padding()
            Spacer() // 一番下にバネを仕込んで上に上げる。 
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

UIKitをUIViewRepresentableで使う

makeUIViewとupdateUIViewを実装すれば良い。プレビューも効く。

import SwiftUI
import MapKit

struct MapView: UIViewRepresentable {
    
    func makeUIView(context:Context) -> MKMapView {
        MKMapView(frame: .zero)
    }
    
    func updateUIView(_ uiView: MKMapView, context: Context) {
        let coordinate = CLLocationCoordinate2D(latitude: 34.011286, longitude: -116.166868)
        let span = MKCoordinateSpan(latitudeDelta: 2.0, longitudeDelta: 2.0)
        let region = MKCoordinateRegion(center: coordinate, span: span)
        uiView.setRegion(region, animated: true)
    }
    
}

カスタムイメージ

カスタムビューを作ることは簡単にできる。これは丸く切り取ったもの

struct CircleImage: View {
    var body: some View {
        Image("turtlerock")
            .clipShape(Circle())
            .overlay(Circle().stroke(Color.white)).shadow(radius: 10)
    }
}

Hello, World

まず、もっとも簡単な使い方をしてみた。

    @State var tapped = false
    
    var tap: some Gesture {
        TapGesture(count: 1)
        .onEnded({ _ in
            self.tapped = !self.tapped;
        })
    }
    
    var circle : some View {
        Circle()
            .fill(self.tapped ? Color.red : Color.black)
            .frame(width: 100, height: 100, alignment: .center)    }
    
    var body: some View {
        VStack {
            Text("Hello, World")
                .gesture(tap)
            circle;
        }
    }

LFS

medium.com

qiita.com

www.slideshare.net

gitで管理されるべきでないバイナリファイルなどをgit上で管理するための機能らしい。 gitはハッシュ値と保存先をリポジトリで管理している。バイナリファイルは他のストレージに保存されている。 チェックアウトする際にはコミットに含まれているバイナリファイルがストレージからとってこられる。

SourctreeでLFSは簡単にインストールして扱うことができる。

長押し系

ひとまず色々あるのでまとめて

/* 画像のクリックなどのイベントを無視  */
img {
    pointer-events: none;
    user-select: none;
    -moz-user-select: none;
    -webkit-user-select: none;
    -ms-user-select: none;
    -webkit-user-drag: none;    
}