SwiftUI onChange capture - swiftui

In many tutorials, it is recommended to put [t] in the .onChange closure. For example
.onChange(of: t) { [t] newValue in
let oldValue = t
if newValue % oldValue == 2 {
print("2")
} else {
print("not 2")
}
}
What's the reason for that? It looks no difference if i remove it.

[t] is the equivalent of oldValue, if you don't need the oldValue you don't have to add it. Your code is the same as:
.onChange(of: t) { [t] newValue in
if newValue % t == 2 {
print("2")
} else {
print("not 2")
}
}

Related

SwiftUI: Custom binding that get value from #StateObject property is set back to old value after StateObject property change

I'm trying to implement a passcode view in iOS. I was following the guide here.
I'm trying to improve it a bit so it allows me to create a passcode by enter same passcode twice. I added a "state" property to the #StateObject and want to clear entered passcode after user input the passcode first time.
Here is my current code:
LockScreenModel.swift
====================
import Foundation
class LockScreenModel: ObservableObject {
#Published var pin: String = ""
#Published var showPin = false
#Published var isDisabled = false
#Published var state = LockScreenState.normal
}
enum LockScreenState: String, CaseIterable {
case new
case verify
case normal
case remove
}
====================
LockScreen.swift
====================
import SwiftUI
struct LockScreen: View {
#StateObject var lockScreenModel = LockScreenModel()
let initialState: LockScreenState
var handler: (String, LockScreenState, (Bool) -> Void) -> Void
var body: some View {
VStack(spacing: 40) {
Text(NSLocalizedString("lock.label.\(lockScreenModel.state.rawValue)", comment: "")).font(.title)
ZStack {
pinDots
backgroundField
}
showPinStack
}
.onAppear(perform: {lockScreenModel.state = initialState})
.onDisappear(perform: {
lockScreenModel.pin = ""
lockScreenModel.showPin = false
lockScreenModel.isDisabled = false
lockScreenModel.state = .normal
})
}
private var pinDots: some View {
HStack {
Spacer()
ForEach(0..<6) { index in
Image(systemName: self.getImageName(at: index))
.font(.system(size: 30, weight: .thin, design: .default))
Spacer()
}
}
}
private var backgroundField: some View {
let boundPin = Binding<String>(get: { lockScreenModel.pin }, set: { newValue in
if newValue.last?.isWholeNumber == true {
lockScreenModel.pin = newValue
}
self.submitPin()
})
return TextField("", text: boundPin, onCommit: submitPin)
.accentColor(.clear)
.foregroundColor(.clear)
.keyboardType(.numberPad)
.disabled(lockScreenModel.isDisabled)
}
private var showPinStack: some View {
HStack {
Spacer()
if !lockScreenModel.pin.isEmpty {
showPinButton
}
}
.frame(height: 20)
.padding([.trailing])
}
private var showPinButton: some View {
Button(action: {
lockScreenModel.showPin.toggle()
}, label: {
lockScreenModel.showPin ?
Image(systemName: "eye.slash.fill").foregroundColor(.primary) :
Image(systemName: "eye.fill").foregroundColor(.primary)
})
}
private func submitPin() {
guard !lockScreenModel.pin.isEmpty else {
lockScreenModel.showPin = false
return
}
if lockScreenModel.pin.count == 6 {
lockScreenModel.isDisabled = true
handler(lockScreenModel.pin, lockScreenModel.state) { isSuccess in
if isSuccess && lockScreenModel.state == .new {
lockScreenModel.state = .verify
lockScreenModel.pin = ""
lockScreenModel.isDisabled = false
} else if !isSuccess {
lockScreenModel.pin = ""
lockScreenModel.isDisabled = false
print("this has to called after showing toast why is the failure")
}
}
}
// this code is never reached under normal circumstances. If the user pastes a text with count higher than the
// max digits, we remove the additional characters and make a recursive call.
if lockScreenModel.pin.count > 6 {
lockScreenModel.pin = String(lockScreenModel.pin.prefix(6))
submitPin()
}
}
private func getImageName(at index: Int) -> String {
if index >= lockScreenModel.pin.count {
return "circle"
}
if lockScreenModel.showPin {
return lockScreenModel.pin.digits[index].numberString + ".circle"
}
return "circle.fill"
}
}
extension String {
var digits: [Int] {
var result = [Int]()
for char in self {
if let number = Int(String(char)) {
result.append(number)
}
}
return result
}
}
extension Int {
var numberString: String {
guard self < 10 else { return "0" }
return String(self)
}
}
====================
The problem is the line lockScreenModel.state = .verify. If I include this line, the passcode TextField won't get cleared, but if I remove this line, the passcode TextField is cleared.
If I add a breakpoint in set method of boundPin, I can see after set pin to empty and state to verify, the set method of boundPin is called with newValue of the old pin which I have no idea why. If I only set pin to empty but don't set state to verify, that set method of boundPin won't get called which confuse me even more. I can't figure out which caused this strange behavior.

SwiftUI move List items between list Sections

In the code bellow I am trying to achieve the moving (dragging) from a list sections to another list section.
I have an enum which contains 5 elements and a state var (activeElements) that contains an array of those enum.
In a view there is a list with 2 sections:
first section contains elements that are selected from the section 2 (let's call them active elements)
the second section contains elements from the enum which are not contained in selected array
In section 1 I should be able to move elements around and also to drag an element to section2 at a desired position.
In section 2 I should be able to drag elements to section 1 and I don't care about the moving elements inside this section.
The section1 works perfect. My problem is that I cannot drag from section2 to section1. I can only drag from section 2 to section 2 (the plus green dot appears only there).
If I add onMove to the section 2 I lose the dragging from section 1 to section 2. The sections will move elements only inside them.
Do you have any suggestions on how to achieve moving from a section to another? Or maybe how I can move elements between 2 foreach in the same section.
Here is the code:
enum RandomElements: Int, CaseIterable {
case element1 = 1
case element2 = 2
case element3 = 3
case element4 = 4
case element5 = 5
}
extension RandomElements {
var string: String {
switch self {
case .element1:
return "element1"
case .element2:
return "element2"
case .element3:
return "element3"
case .element4:
return "element4"
case .element5:
return "element5"
}
}
}
struct TwoSections: View {
#State var activeElements: [RandomElements] = []
#State var listMode: EditMode = .inactive
var body: some View {
List {
Section(header: Text("Active elements")) {
ForEach(activeElements, id: \.self) { elem in
HStack {
Text(elem.string)
Spacer()
Image(systemName: "minus")
.onTapGesture { activeElements.remove(at: activeElements.firstIndex(of: elem)!) }
}
.onDrag { NSItemProvider(object: String(elem.rawValue) as NSString ) }
}
.onInsert(of: [.plainText], perform: dropToSection1)
.onMove { (indexSet, index) in
activeElements.move(fromOffsets: indexSet, toOffset: index)
}
}
Section(header: Text("Available elements")) {
ForEach(RandomElements.allCases, id: \.self) { elem in
if !activeElements.contains(elem) {
HStack {
Text(elem.string)
Spacer()
Image(systemName: "plus")
.onTapGesture { activeElements.append(elem) }
}
.onDrag { NSItemProvider(object: String(elem.rawValue) as NSString ) }
}
}
.onInsert(of: [.plainText], perform: dropToSection2)
// .onMove { (indexSet, index) in
// }
}
}
.toolbar {
EditButton()
}
.environment(\.editMode, .constant(.active))
}
private func dropToSection1(at index: Int, _ items: [NSItemProvider]) {
for item in items {
_ = item.loadObject(ofClass: String.self) { droppedString, _ in
if let statusType = Int(droppedString ?? "") {
activeElements.remove(at: activeElements.firstIndex(of: RandomElements(rawValue: statusType)!)!)
}
}
}
}
private func dropToSection2(at index: Int, _ items: [NSItemProvider]) {
for item in items {
_ = item.loadObject(ofClass: String.self) { droppedString, _ in
if let statusType = Int(droppedString ?? "") {
print("append \(RandomElements(rawValue: statusType)!)")
activeElements.insert(RandomElements(rawValue: statusType)!, at: index)
}
}
}
}
}

TabView disconnects when rotating to Landscape due to SwiftUI's re-render of parent-Views

Using Swift5.3.2, iOS14.4.1, XCode12.4,
As the following code shows, I am working with a quite complex TabView in Page-Mode in SwiftUI.
i.e. using iOS14's new possibility to show Pages:
.tabViewStyle(PageTabViewStyle())
Everything works.
Except, if I rotate my iPhone from Portrait to Landscape, the TabView disconnects and sets the selectedTab index to 0 (i.e. no matter where you scrolled to, rotating iPhone resets unwontedly to page 0).
The parent-View itself is in a complex View hierarchy. And one of the parent-View's of the TabView is updated during the TabView is shown (and swiped). And this might be the problem that the TabView gets re-rendered when rotating to Landscape.
What can I do to keep the TabView-Page during iPhone rotation ??
Here is the code:
import SwiftUI
struct PageViewiOS: View {
var body: some View {
ZStack {
Color.black
MediaTabView()
CloseButtonView()
}
}
}
And the MediaTabView at question:
struct MediaTabView: View {
#EnvironmentObject var appStateService: AppStateService
#EnvironmentObject var commService: CommunicationService
#State private var tagID = ""
#State private var selectedTab = 0
#State private var uniqueSelected = 0
#State private var IamInSwipingAction = false
var body: some View {
let myDragGesture = DragGesture(minimumDistance: 10)
.onChanged { _ in
IamInSwipingAction = true
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(5000)) {
IamInSwipingAction = false // workaround: because onEnded does not work...
}
}
.onEnded { _ in
IamInSwipingAction = false
}
TabView(selection: self.$selectedTab) {
if let list = appStateService.mediaViewModel.mediaList.first(where: { (list) -> Bool in
switch appStateService.appState {
case .content(let tagID):
return list.tagId == tagID
default:
return false
}
}) {
if list.paths.count > 0 {
ForEach(list.paths.indices, id: \.self) { index in
ZoomableScrollView {
if let url = URL(fileURLWithPath: list.paths[index]){
if url.containsImage {
Image(uiImage: UIImage(contentsOfFile: url.path)!)
.resizable()
.scaledToFit()
} else if url.containsVideo {
CustomPlayerView(url: url)
} else {
Text(LocalizedStringKey("MediaNotRecognizedKey"))
.multilineTextAlignment(.center)
.padding()
}
} else {
Text(LocalizedStringKey("MediaNotRecognizedKey"))
.multilineTextAlignment(.center)
.padding()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.black)
.onAppear() {
if uniqueSelected != selectedTab {
uniqueSelected = selectedTab
if IamInSwipingAction && (commService.communicationRole == .moderatorMode) {
commService.send(thCmd: THCmd(key: .swipeID, sender: "", content: URL(fileURLWithPath: list.paths[index]).lastPathComponent))
}
}
}
}
} else {
Text(LocalizedStringKey("EmptyOrNoTrihowAlbumKey"))
.multilineTextAlignment(.center)
.padding()
}
} else {
if Constants.TrihowAlbum.tagIdArrayTrihowAlbum.contains(tagID) {
Text(LocalizedStringKey("EmptyOrNoTrihowAlbumKey"))
.multilineTextAlignment(.center)
.padding()
} else {
Text(LocalizedStringKey("TagNotRecognizedKey"))
.multilineTextAlignment(.center)
.padding()
}
}
}
.onAppear() {
switch appStateService.appState {
case .content(let tagID):
self.tagID = tagID
default:
self.tagID = ""
}
}
.tabViewStyle(PageTabViewStyle())
.onTHComm_ReceiveCmd(service: commService) { (thCmd) in
switch thCmd.key {
case .swipeID:
if (commService.communicationRole == .moderatorMode) || (commService.communicationRole == .discoveryMode) {
selectTabFromCmdID(fileName: thCmd.content)
} else {
break
}
default:
break
}
}
.simultaneousGesture(myDragGesture)
}
}
extension MediaTabView {
private func selectTabFromCmdID(fileName: String) {
if let list = appStateService.mediaViewModel.mediaList.first(where: { (list) -> Bool in
return list.tagId == tagID
}) {
if list.paths.count > 0 {
if let idx = list.paths.firstIndex(where: { (urlPath) -> Bool in
if let url = URL(string: urlPath) {
return url.lastPathComponent == fileName
} else { return false }
}) {
selectedTab = idx
}
}
}
}
}

Form Currency Input

I'm looking for help inputting currency via a form. I would like to have:
Allow entry of a single decimal / comma currency separator
Limit entry to 2 digits past the decimal
Allow value editing
Limit maximum digits entered
There is a similar question asked 6 years ago at Limiting user input to a valid decimal number in Swift, but all the answers appear to be using viewDidLoad. I don't think that is feasible from within a form entry. If it is feasible, please show me how. Thanks
Form {
...
// Enter entry amount
Section {
TextField("Enter Amount > " + curr, text: $moneyS)
.keyboardType(.decimalPad)
}
// select entry save or cancel
Section {
Button(action: {
self.showingAlert.toggle()
...
Following the idea to achieve this, not covering all your points but I think is enough to procede by yourself:
class Test2Model: ObservableObject {
#Published var currLimit: Double = 4.0
#Published var digitLimit: Int = 2
func getCurr(str: String) -> String{
guard let currInserted = Double(str)
else {
return String(currLimit)
}
if currInserted <= currLimit {
return String(currLimit)
}
return str
}
}
struct Test2View: View {
#ObservedObject private var test2Model = Test2Model()
#State private var moneyS: String = ""
var body: some View {
Form {
// Enter entry amount
Section {
TextField("Enter Amount > " + String(test2Model.currLimit), text: $moneyS)
.keyboardType(.decimalPad)
.onChange(of: moneyS, perform: { value in
guard let decimals = moneyS.components(separatedBy:".").last else {return}
if decimals.count > test2Model.digitLimit {
moneyS.removeLast()
}
})
}
// select entry save or cancel
Section {
Button(action: {
moneyS = test2Model.getCurr(str: moneyS)
}, label: {
Text("Check")
})
}
}
}
}
struct Test2View_Previews: PreviewProvider {
static var previews: some View {
Test2View()
}
}
you could try something like this:
struct ContentView: View {
let maxDigits = 6
let maxDecimals = 2
let allowedCharacters = CharacterSet.decimalDigits.union(CharacterSet(charactersIn: NumberFormatter().decimalSeparator))
#State var money: Double?
#State var moneyText = ""
var body: some View {
VStack (spacing: 30) {
Spacer()
TextField("enter a number", text: $moneyText)
.padding(.horizontal, 20)
.keyboardType(.decimalPad)
.onChange(of: moneyText) {
// to prevent pasting non-valid text
let txt = $0.filter { ".0123456789".contains($0) }
if allowed(txt) {
money = Double(txt)
moneyText = txt
} else {
moneyText = String(txt.dropLast())
}
}
Text("Money value is: \(money ?? 0)")
Spacer()
}
}
func allowed(_ txt: String) -> Bool {
let str = txt.trimmingCharacters(in: .whitespacesAndNewlines)
switch str.components(separatedBy: ".").count - 1 {
case 0:
if str.count > maxDigits {
return false
}
return allowedCharacters.isSuperset(of: CharacterSet(charactersIn: str))
case 1:
if str.starts(with: ".") && (str.count - 1) > maxDecimals {
return false
}
let parts = str.split(separator: ".")
if parts.count == 2 && (parts[1].count > maxDecimals) || (str.count - 1) > maxDigits {
return false
}
return allowedCharacters.isSuperset(of: CharacterSet(charactersIn: str))
default:
return false
}
}
}

How to detect Swiping UP, DOWN, LEFT and RIGHT with SwiftUI on a View

I'm getting into building Apple Watch apps.
What I'm currently working on will require me to make use of detecting swipes in the four main directions (UP, DOWN, LEFT and RIGHT)
The problem is I have no idea how to detect this. I've been looking around and I'm reaching dead ends.
What can I do to my view below to just print swiped up when the user swipes UP on the view?
struct MyView: View {
var body: some View {
Text("Hello, World!")
}
}
Thanks.
You could use DragGesture
.gesture(DragGesture(minimumDistance: 0, coordinateSpace: .local)
.onEnded({ value in
if value.translation.width < 0 {
// left
}
if value.translation.width > 0 {
// right
}
if value.translation.height < 0 {
// up
}
if value.translation.height > 0 {
// down
}
}))
With the other solutions being a bit inconsistent on a physical device, I decided to come up with another one that seems to be much more consistent across different screen sizes as there are no hardcoded values except for the minimumDistance.
.gesture(DragGesture(minimumDistance: 20, coordinateSpace: .global)
.onEnded { value in
let horizontalAmount = value.translation.width
let verticalAmount = value.translation.height
if abs(horizontalAmount) > abs(verticalAmount) {
print(horizontalAmount < 0 ? "left swipe" : "right swipe")
} else {
print(verticalAmount < 0 ? "up swipe" : "down swipe")
}
})
If you want one that is more "forgiving" to the directionality of the swipe, you can use a few more conditionals to help even it out:
EDIT: did some more testing, apparently the values for the second conditional add some confusion, so I adjusted them to remove said confusion and make the gesture bulletproof (drags to the corners will now come up with "no clue" instead of one of the gestures)...
let detectDirectionalDrags = DragGesture(minimumDistance: 3.0, coordinateSpace: .local)
.onEnded { value in
print(value.translation)
if value.translation.width < 0 && value.translation.height > -30 && value.translation.height < 30 {
print("left swipe")
}
else if value.translation.width > 0 && value.translation.height > -30 && value.translation.height < 30 {
print("right swipe")
}
else if value.translation.height < 0 && value.translation.width < 100 && value.translation.width > -100 {
print("up swipe")
}
else if value.translation.height > 0 && value.translation.width < 100 && value.translation.width > -100 {
print("down swipe")
}
else {
print("no clue")
}
Based on Benjamin's answer this is a swiftier way to handle the cases
.gesture(DragGesture(minimumDistance: 3.0, coordinateSpace: .local)
.onEnded { value in
print(value.translation)
switch(value.translation.width, value.translation.height) {
case (...0, -30...30): print("left swipe")
case (0..., -30...30): print("right swipe")
case (-100...100, ...0): print("up swipe")
case (-100...100, 0...): print("down swipe")
default: print("no clue")
}
}
)
Create an extension:
extension View {
func swipe(
up: #escaping (() -> Void) = {},
down: #escaping (() -> Void) = {},
left: #escaping (() -> Void) = {},
right: #escaping (() -> Void) = {}
) -> some View {
return self.gesture(DragGesture(minimumDistance: 0, coordinateSpace: .local)
.onEnded({ value in
if value.translation.width < 0 { left() }
if value.translation.width > 0 { right() }
if value.translation.height < 0 { up() }
if value.translation.height > 0 { down() }
}))
}
}
Then:
Image() // or any View
.swipe(
up: {
// action for up
},
right: {
// action for right
})
Notice that each direction is an optional parameter
I would create a modifier for simplicity. Usage will look like that:
yourView
.onSwiped(.down) {
// Action for down swipe
}
OR
yourView
.onSwiped { direction in
// React to detected swipe direction
}
You can also use trigger parameter in order to configure receiving updates: continuously or only when the gesture ends.
Here's the full code:
struct SwipeModifier: ViewModifier {
enum Directions: Int {
case up, down, left, right
}
enum Trigger {
case onChanged, onEnded
}
var trigger: Trigger
var handler: ((Directions) -> Void)?
func body(content: Content) -> some View {
content.gesture(
DragGesture(
minimumDistance: 24,
coordinateSpace: .local
)
.onChanged {
if trigger == .onChanged {
handle($0)
}
}.onEnded {
if trigger == .onEnded {
handle($0)
}
}
)
}
private func handle(_ value: _ChangedGesture<DragGesture>.Value) {
let hDelta = value.translation.width
let vDelta = value.translation.height
if abs(hDelta) > abs(vDelta) {
handler?(hDelta < 0 ? .left : .right)
} else {
handler?(vDelta < 0 ? .up : .down)
}
}
}
extension View {
func onSwiped(
trigger: SwipeModifier.Trigger = .onChanged,
action: #escaping (SwipeModifier.Directions) -> Void
) -> some View {
let swipeModifier = SwipeModifier(trigger: trigger) {
action($0)
}
return self.modifier(swipeModifier)
}
func onSwiped(
_ direction: SwipeModifier.Directions,
trigger: SwipeModifier.Trigger = .onChanged,
action: #escaping () -> Void
) -> some View {
let swipeModifier = SwipeModifier(trigger: trigger) {
if direction == $0 {
action()
}
}
return self.modifier(swipeModifier)
}
}
This is much more responsive:
.gesture(DragGesture(minimumDistance: 3.0, coordinateSpace: .local)
.onEnded { value in
let direction = atan2(value.translation.width, value.translation.height)
switch direction {
case (-Double.pi/4..<Double.pi/4): self.playMove(.down)
case (Double.pi/4..<Double.pi*3/4): self.playMove(.right)
case (Double.pi*3/4...Double.pi), (-Double.pi..<(-Double.pi*3/4)):
self.playMove(.up)
case (-Double.pi*3/4..<(-Double.pi/4)): self.playMove(.left)
default:
print("unknown)")
}
}
Little bit late to this, but here's another implementation which uses OptionSet to make its use a bit more like various other SwiftUI components -
struct Swipe: OptionSet, Equatable {
init(rawValue: Int) {
self.rawValue = rawValue
}
let rawValue: Int
fileprivate var swiped: ((DragGesture.Value, Double) -> Bool) = { _, _ in false } // prevents a crash if someone creates a swipe directly using the init
private static let sensitivityFactor: Double = 400 // a fairly arbitrary figure which gives a reasonable response
static var left: Swipe {
var swipe = Swipe(rawValue: 1 << 0)
swipe.swiped = { value, sensitivity in
value.translation.width < 0 && value.predictedEndTranslation.width < sensitivity * sensitivityFactor
}
return swipe
}
static var right: Swipe {
var swipe = Swipe(rawValue: 1 << 1)
swipe.swiped = { value, sensitivity in
value.translation.width > 0 && value.predictedEndTranslation.width > sensitivity * sensitivityFactor
}
return swipe
}
static var up: Swipe {
var swipe = Swipe(rawValue: 1 << 2)
swipe.swiped = { value, sensitivity in
value.translation.height < 0 && value.predictedEndTranslation.height < sensitivity * sensitivityFactor
}
return swipe
}
static var down: Swipe {
var swipe = Swipe(rawValue: 1 << 3)
swipe.swiped = { value, sensitivity in
value.translation.height > 0 && value.predictedEndTranslation.height > sensitivity * sensitivityFactor
}
return swipe
}
static var all: Swipe {
[.left, .right, .up, .down]
}
private static var allCases: [Swipe] = [.left, .right, .up, .down]
fileprivate var array: [Swipe] {
Swipe.allCases.filter { self.contains($0) }
}
}
extension View {
func swipe(_ swipe: Swipe, sensitivity: Double = 1, action: #escaping (Swipe) -> ()) -> some View {
return gesture(DragGesture(minimumDistance: 30, coordinateSpace: .local)
.onEnded { value in
swipe.array.forEach { swipe in
if swipe.swiped(value, sensitivity) {
action(swipe)
}
}
}
)
}
}
In a SwiftUI view -
HStack {
// content
}
.swipe([.left, .right]) { swipe in // could also be swipe(.left) or swipe(.all), etc
doSomething(with: swipe)
}
Obviously the logic for detecting swipes is a bit basic, but that's easy enough to tailor to your requirements.