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