UINotificationFeedbackGeneratorでiPhoneを振動させる
Making iPhones vibrate with UINotificationFeedbackGenerator
UINotificationFeedbackGenerator
フィードバックをより正確に制御するには、まずそのprepare()メソッドを呼び出してTaptic Engineにウォームアップの機会を与える必要があります。
UIFeedbackGenerator
UINotificationFeedbackGenerator
カードが取り外されているときは常に通知されますが、されていないドラッグが進行中である場合に通知
タプティックエンジンの準備ができていないためにハプティックが遅れるリスクが常にあります
prepare() →→→→→→ play()
// // CardView.swift // Flashzilla import SwiftUI struct CardView: View { @State private var isShowingAnswer = false @State private var offset = CGSize.zero @Environment(\.accessibilityDifferentiateWithoutColor) var differentiateWithoutColor //For haptics @State private var feedback = UINotificationFeedbackGenerator() let card: Card var removal: (() -> Void)? = nil var body: some View { ZStack { /* RoundedRectangle(cornerRadius: 25, style: .continuous) //.fill(Color.white) .fill( Color.white .opacity(1 - Double(abs(offset.width / 50))) ) .background( RoundedRectangle(cornerRadius: 25, style: .continuous) .fill(offset.width > 0 ? Color.green : Color.red) ) .shadow(radius: 10) */ RoundedRectangle(cornerRadius: 25, style: .continuous) .fill( differentiateWithoutColor ? Color.white : Color.white .opacity(1 - Double(abs(offset.width / 50))) ) .background( differentiateWithoutColor ? nil : RoundedRectangle(cornerRadius: 25, style: .continuous) .fill(offset.width > 0 ? Color.green : Color.red) ) .shadow(radius: 10) VStack { Text(card.prompt) .font(.largeTitle) .foregroundColor(.black) if isShowingAnswer { Text(card.answer) .font(.title) .foregroundColor(.gray) } } .padding(20) .multilineTextAlignment(.center) } .frame(width: 450, height: 250) .rotationEffect(.degrees(Double(offset.width / 5))) .offset(x: offset.width * 5, y: 0) .opacity(2 - Double(abs(offset.width / 50))) .gesture( DragGesture() .onChanged { offset in self.offset = offset.translation //For haptics self.feedback.prepare() } .onEnded { _ in if abs(self.offset.width) > 100 { // for haptics if self.offset.width > 0 { self.feedback.notificationOccurred(.success) } else { self.feedback.notificationOccurred(.error) } // remove the card self.removal?() } else { self.offset = .zero } } ) .onTapGesture { self.isShowingAnswer.toggle() } } } struct CardView_Previews: PreviewProvider { static var previews: some View { CardView(card: Card.example) } }
バグを修正する
Fixing the bugs
// // CardView.swift // Flashzilla import SwiftUI struct CardView: View { @State private var isShowingAnswer = false @State private var offset = CGSize.zero @Environment(\.accessibilityDifferentiateWithoutColor) var differentiateWithoutColor //For haptics @State private var feedback = UINotificationFeedbackGenerator() @Environment(\.accessibilityEnabled) var accessibilityEnabled let card: Card var removal: (() -> Void)? = nil var body: some View { ZStack { /* RoundedRectangle(cornerRadius: 25, style: .continuous) //.fill(Color.white) .fill( Color.white .opacity(1 - Double(abs(offset.width / 50))) ) .background( RoundedRectangle(cornerRadius: 25, style: .continuous) .fill(offset.width > 0 ? Color.green : Color.red) ) .shadow(radius: 10) */ RoundedRectangle(cornerRadius: 25, style: .continuous) .fill( differentiateWithoutColor ? Color.white : Color.white.opacity(1 - Double(abs(offset.width / 50))) ) .accessibility(addTraits: .isButton) .background( differentiateWithoutColor ? nil : RoundedRectangle(cornerRadius: 25, style: .continuous) .fill(offset.width > 0 ? Color.green : Color.red) ) .shadow(radius: 10) VStack { if accessibilityEnabled { Text(isShowingAnswer ? card.answer : card.prompt) .font(.largeTitle) .foregroundColor(.black) } else { Text(card.prompt) .font(.largeTitle) .foregroundColor(.black) if isShowingAnswer { Text(card.answer) .font(.title) .foregroundColor(.gray) } } } .padding(20) .multilineTextAlignment(.center) } .frame(width: 450, height: 250) .rotationEffect(.degrees(Double(offset.width / 5))) .offset(x: offset.width * 5, y: 0) .opacity(2 - Double(abs(offset.width / 50))) .gesture( DragGesture() .onChanged { offset in self.offset = offset.translation //For haptics self.feedback.prepare() } .onEnded { _ in if abs(self.offset.width) > 100 { // for haptics if self.offset.width > 0 { self.feedback.notificationOccurred(.success) } else { self.feedback.notificationOccurred(.error) } // remove the card self.removal?() } else { self.offset = .zero } } ) .onTapGesture { self.isShowingAnswer.toggle() } .animation(.spring()) } } struct CardView_Previews: PreviewProvider { static var previews: some View { CardView(card: Card.example) } }
// // ContentView.swift // Flashzilla import SwiftUI //------------- struct ContentView: View { // For Card @State private var cards = [Card](repeating: Card.example, count: 10) @Environment(\.accessibilityDifferentiateWithoutColor) var differentiateWithoutColor @Environment(\.accessibilityEnabled) var accessibilityEnabled @State private var timeRemaining = 100 let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() @State private var isActive = true func removeCard(at index: Int) { guard index >= 0 else { return } cards.remove(at: index) //タイマー停止 if cards.isEmpty { isActive = false } } //再試行できるようにアプリをリセット func resetCards() { cards = [Card](repeating: Card.example, count: 10) timeRemaining = 100 isActive = true } var body: some View { ZStack { Image(decorative: "background") .resizable() .scaledToFill() .edgesIgnoringSafeArea(.all) VStack { //timeRemaining1 Text("Time: \(timeRemaining)") .font(.largeTitle) .foregroundColor(.white) .padding(.horizontal, 20) .padding(.vertical, 5) .background( Capsule() .fill(Color.black) .opacity(0.75) ) ZStack { ForEach(0..<cards.count, id: \.self) { index in CardView(card: self.cards[index]) { withAnimation { self.removeCard(at: index) } } .stacked(at: index, in: self.cards.count) .allowsHitTesting(index == self.cards.count - 1) .accessibility(hidden: index < self.cards.count - 1) } } //タイムアウトが発生したときにカードのスワイプ無効 .allowsHitTesting(timeRemaining > 0) if cards.isEmpty { Button("Start Again", action: resetCards) .padding() .background(Color.white) .foregroundColor(.black) .clipShape(Capsule()) } } //if differentiateWithoutColor || accessibilityEnabled { VStack { Spacer() HStack { Button(action: { withAnimation { self.removeCard(at: self.cards.count - 1) } }) { Image(systemName: "xmark.circle") .padding() .background(Color.black.opacity(0.7)) .clipShape(Circle()) } .accessibility(label: Text("Wrong")) .accessibility(hint: Text("Mark your answer as being incorrect.")) //Spacer() Button(action: { withAnimation { self.removeCard(at: self.cards.count - 1) } }) { Image(systemName: "checkmark.circle") .padding() .background(Color.black.opacity(0.7)) .clipShape(Circle()) } .accessibility(label: Text("Correct")) .accessibility(hint: Text("Mark your answer as being correct.")) } .foregroundColor(.white) .font(.largeTitle) .padding() } //} } .onReceive(timer) { time in guard self.isActive else { return } if self.timeRemaining > 0 { self.timeRemaining -= 1 } } .onReceive(NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification)) { _ in self.isActive = false } .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in if self.cards.isEmpty == false { self.isActive = true } } } } //------------- extension View { func stacked(at position: Int, in total: Int) -> some View { //let offset = CGFloat(total - position) //配列内の場所ごとにビューを10ポイント押し下げます(0、次に10、20、30など) // return self.offset(CGSize(width: 0, height: position * 10)) let offset = CGFloat(total - position) return self.offset(CGSize(width: 0, height: offset * 10)) } } //------------- struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
カードの追加と削除
Adding and deleting cards
既存のすべてのカードを一覧表示する新しいビューを追加し、ユーザーが新しいカードを追加できるようにすることを意味します。
EditCards
独自のCard配列を持っています。
Aに包まれたことがNavigationView、我々はビューを却下する[完了]ボタンを追加できるように。
すべての既存のカードを示すリストを持っています。
スワイプを追加して、それらのカードを削除します。
ユーザーが新しいカードを追加できるように、リストの上部にセクションを配置します。
からデータをロードおよび保存するメソッドがありますUserDefaults。
一部のiPhoneでランドスケープモードで実行している場合、iOSでは2つのビューを並べて配置できます。左側のビューによって右側のビューの表示が決まります。ポートレートとランドスケープの間を移動するときに2つのビューがどのように機能するかをカスタマイズできますが、SwiftUIのデフォルトでは、右側のビュー(詳細ビュー)のみが表示され、この場合は実際にはありません。そのため、空白の画面。
// // EditCards.swift // Flashzilla import SwiftUI struct EditCards: View { @Environment(\.presentationMode) var presentationMode @State private var cards = [Card]() @State private var newPrompt = "" @State private var newAnswer = "" var body: some View { NavigationView { List { Section(header: Text("Add new card")) { TextField("Prompt", text: $newPrompt) TextField("Answer", text: $newAnswer) Button("Add card", action: addCard) } Section { ForEach(0..<cards.count, id: \.self) { index in VStack(alignment: .leading) { Text(self.cards[index].prompt) .font(.headline) Text(self.cards[index].answer) .foregroundColor(.secondary) } } .onDelete(perform: removeCards) } } .navigationBarTitle("Edit Cards") .navigationBarItems(trailing: Button("Done", action: dismiss)) .listStyle(GroupedListStyle()) .onAppear(perform: loadData) } .navigationViewStyle(StackNavigationViewStyle()) } func dismiss() { presentationMode.wrappedValue.dismiss() } func loadData() { if let data = UserDefaults.standard.data(forKey: "Cards") { if let decoded = try? JSONDecoder().decode([Card].self, from: data) { self.cards = decoded } } } func saveData() { if let data = try? JSONEncoder().encode(cards) { UserDefaults.standard.set(data, forKey: "Cards") } } func addCard() { let trimmedPrompt = newPrompt.trimmingCharacters(in: .whitespaces) let trimmedAnswer = newAnswer.trimmingCharacters(in: .whitespaces) guard trimmedPrompt.isEmpty == false && trimmedAnswer.isEmpty == false else { return } let card = Card(prompt: trimmedPrompt, answer: trimmedAnswer) cards.insert(card, at: 0) saveData() } func removeCards(at offsets: IndexSet) { cards.remove(atOffsets: offsets) saveData() } } struct EditCards_Previews: PreviewProvider { static var previews: some View { EditCards() } }
// // ContentView.swift // Flashzilla import SwiftUI //------------- struct ContentView: View { // For Card test data //@State private var cards = [Card](repeating: Card.example, count: 10) @State private var cards = [Card]() @State private var showingEditScreen = false @Environment(\.accessibilityDifferentiateWithoutColor) var differentiateWithoutColor @Environment(\.accessibilityEnabled) var accessibilityEnabled @State private var timeRemaining = 100 let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() @State private var isActive = true func removeCard(at index: Int) { guard index >= 0 else { return } cards.remove(at: index) //タイマー停止 if cards.isEmpty { isActive = false } } //再試行できるようにアプリをリセット /* func resetCards() { cards = [Card](repeating: Card.example, count: 10) timeRemaining = 100 isActive = true } */ func resetCards() { timeRemaining = 100 isActive = true loadData() } func loadData() { if let data = UserDefaults.standard.data(forKey: "Cards") { if let decoded = try? JSONDecoder().decode([Card].self, from: data) { self.cards = decoded } } } var body: some View { ZStack { Image(decorative: "background") .resizable() .scaledToFill() .edgesIgnoringSafeArea(.all) VStack { //timeRemaining1 Text("Time: \(timeRemaining)") .font(.largeTitle) .foregroundColor(.white) .padding(.horizontal, 20) .padding(.vertical, 5) .background( Capsule() .fill(Color.black) .opacity(0.75) ) ZStack { ForEach(0..<cards.count, id: \.self) { index in CardView(card: self.cards[index]) { withAnimation { self.removeCard(at: index) } } .stacked(at: index, in: self.cards.count) .allowsHitTesting(index == self.cards.count - 1) .accessibility(hidden: index < self.cards.count - 1) } } //タイムアウトが発生したときにカードのスワイプ無効 .allowsHitTesting(timeRemaining > 0) if cards.isEmpty { Button("Start Again", action: resetCards) .padding() .background(Color.white) .foregroundColor(.black) .clipShape(Capsule()) } } // For editbuton EditCards VStack { HStack { //Spacer() Button(action: { self.showingEditScreen = true }) { Image(systemName: "plus.circle") .padding() .background(Color.black.opacity(0.7)) .clipShape(Circle()) } } Spacer() } .foregroundColor(.white) .font(.largeTitle) .padding() //if differentiateWithoutColor || accessibilityEnabled { VStack { Spacer() HStack { Button(action: { withAnimation { self.removeCard(at: self.cards.count - 1) } }) { Image(systemName: "xmark.circle") .padding() .background(Color.black.opacity(0.7)) .clipShape(Circle()) } .accessibility(label: Text("Wrong")) .accessibility(hint: Text("Mark your answer as being incorrect.")) //Spacer() Button(action: { withAnimation { self.removeCard(at: self.cards.count - 1) } }) { Image(systemName: "checkmark.circle") .padding() .background(Color.black.opacity(0.7)) .clipShape(Circle()) } .accessibility(label: Text("Correct")) .accessibility(hint: Text("Mark your answer as being correct.")) } .foregroundColor(.white) .font(.largeTitle) .padding() } //} } .onReceive(timer) { time in guard self.isActive else { return } if self.timeRemaining > 0 { self.timeRemaining -= 1 } } .onReceive(NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification)) { _ in self.isActive = false } .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in if self.cards.isEmpty == false { self.isActive = true } } .sheet(isPresented: $showingEditScreen, onDismiss: resetCards) { EditCards() } .onAppear(perform: resetCards) } } //------------- extension View { func stacked(at position: Int, in total: Int) -> some View { //let offset = CGFloat(total - position) //配列内の場所ごとにビューを10ポイント押し下げます(0、次に10、20、30など) // return self.offset(CGSize(width: 0, height: position * 10)) let offset = CGFloat(total - position) return self.offset(CGSize(width: 0, height: offset * 10)) } } //------------- struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }