Swift UI - animation - terrible performances - performance

I'm trying to display an automatic scrolling text (marquee?) with an animation using Swift UI.
When the mouse is over the text, the animation stops (that's why I store the current state of the animation).
Using one of the latest M1 MBP, this simple animation is using up to 10% of CPU and I'm trying to understand why. Is Swift UI not made for animations like this one or am I doing something wrong? At the end, it's just an animation moving the x offset.
Here is the code of my Marquee.
import SwiftUI
private enum MarqueeState {
case idle
case animating
}
struct GeometryBackground: View {
var body: some View {
GeometryReader { geometry in
Color.clear.preference(key: WidthKey.self, value: geometry.size.width)
}
}
}
struct WidthKey: PreferenceKey {
static var defaultValue = CGFloat(0)
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
typealias Value = CGFloat
}
extension View {
func myOffset(x: CGFloat, y: CGFloat) -> some View {
return modifier(_OffsetEffect(offset: CGSize(width: x, height: y)))
}
func myOffset(_ offset: CGSize) -> some View {
return modifier(_OffsetEffect(offset: offset))
}
}
struct PausableOffsetX: GeometryEffect {
#Binding var currentOffset: CGFloat
#Binding var contentWidth: CGFloat
private var targetOffset: CGFloat = 0.0;
var animatableData: CGFloat {
get { targetOffset }
set { targetOffset = newValue }
}
init(targetOffset: CGFloat, currentOffset: Binding<CGFloat>, contentWidth: Binding<CGFloat>) {
self.targetOffset = targetOffset
self._currentOffset = currentOffset
self._contentWidth = contentWidth
}
public func effectValue(size: CGSize) -> ProjectionTransform {
DispatchQueue.main.async {
self.currentOffset = targetOffset
}
let relativeOffset = targetOffset.truncatingRemainder(dividingBy: contentWidth)
let transform = CGAffineTransform(translationX: relativeOffset, y: 0)
return ProjectionTransform(transform)
}
}
struct Marquee<Content: View> : View {
#State private var isOver: Bool = false
private var content: () -> Content
#State private var state: MarqueeState = .idle
#State private var contentWidth: CGFloat = 0
#State private var isAppear = false
#State private var targetOffsetX: CGFloat = 0
#State private var currentOffsetX: CGFloat
public init(#ViewBuilder content: #escaping () -> Content) {
self.content = content
self.currentOffsetX = 0
}
private func getAnimation() -> Animation {
let duration = contentWidth / 30
print("animation with duration of ", duration)
return Animation.linear(duration: duration).repeatForever(autoreverses: false);
}
public var body : some View {
GeometryReader { proxy in
HStack(alignment: .center, spacing: 0) {
if isAppear {
content()
.overlay(GeometryBackground())
.fixedSize()
content()
.overlay(GeometryBackground())
.fixedSize()
}
}
.modifier(PausableOffsetX(targetOffset: targetOffsetX, currentOffset: $currentOffsetX, contentWidth: $contentWidth))
.onPreferenceChange(WidthKey.self, perform: { value in
if value != self.contentWidth {
self.contentWidth = value
print("Content width = \(value)")
resetAnimation()
}
})
.onAppear {
self.isAppear = true
resetAnimation()
}
.onDisappear {
self.isAppear = false
}
.onHover(perform: { isOver in
self.isOver = isOver
checkAnimation()
})
}
.frame(width: 400)
.clipped()
}
private func getOffsetX() -> CGFloat {
switch self.state {
case .idle:
return self.currentOffsetX
case .animating:
return -self.contentWidth + currentOffsetX
}
}
private func checkAnimation() {
if isOver{
if self.state != .idle {
pauseAnimation()
}
} else {
if self.state != .animating {
resumeAnimation()
}
}
}
private func pauseAnimation() {
withAnimation(.linear(duration: 0)) {
self.state = .idle
self.targetOffsetX = getOffsetX()
}
}
private func resumeAnimation() {
print("Resume animation");
withAnimation(getAnimation()) {
self.state = .animating
self.targetOffsetX = getOffsetX()
}
}
private func resetAnimation() {
withAnimation(.linear(duration: 0)) {
self.currentOffsetX = 0
self.targetOffsetX = 0
self.state = .idle
}
resumeAnimation()
}
}
And we can use it as follow:
Marquee {
Text("Hello, world! Hello, world! Hello, world! Hello, world!").padding().fixedSize()
}.frame(width: 300)
EDIT
I ended up using Core Animation instead of the one built in Swift UI. The cpu / Energy impact is an absolute zero. So I wouldn't recommend using Swift UI animation for long lasting or persistant animations.

Related

SwiftUI animation not continuous after view change

Here is the testing code:
import SwiftUI
struct ContentView: View {
#State private var pad: Bool = false
#State private var showDot: Bool = true
var body: some View {
VStack {
Button {showDot.toggle()} label: {Text("Toggle Show Dot")}
Spacer().frame(height: pad ? 100 : 10)
Circ(showDot: showDot)
Spacer()
}.onAppear {
withAnimation(.linear(duration: 3).repeatForever()) {pad = true}
}
}
}
struct Circ: View {
let showDot: Bool
var body: some View {
Circle().stroke().frame(height: 50).overlay {if showDot {Circle().frame(height: 20)}}
}
}
It happens that after I toggle showDot, the dot circle is not on the center of the stroke circle again! How can I fix this?
The Circ View is given, I can't change that view!
Edit
If you can just hide the view, see solution 1, which is preferred. If you need to re-build the view, see solution 2.
Solution 1
Replace the if condition with a .opacity() modifier that reads 1 when showDot is true.
This way, the dot does not disappear completely, it is there but you just can't see it. You will be toggling the visibility, not the view itself.
Like this:
#State private var pad: Bool = false
#State private var showDot: Bool = true
var body: some View {
VStack {
Button {
showDot.toggle()
} label: {
Text("Toggle Show Dot")
}
Spacer()
.frame(height: pad ? 100 : 10)
Circle().stroke()
.frame(height: 50)
.overlay {
Circle()
.frame(height: 20)
.opacity(showDot ? 1 : 0) // <- Here
}
Spacer()
}
.onAppear {
withAnimation(.linear(duration: 3).repeatForever()) {pad = true}
}
}
Solution 2
You can replace the animation with a timer. Every time it triggers, it will move the whole view by changing the height of the Spacer().
// These variables will track the position and moving direction of the dot
#State private var pos: CGFloat = 0
#State private var movingUp = false
#State private var showDot: Bool = true
// This variable will change the position
// This is a dummy iniatialization, the .onAppear modifier sets the real timer
#State private var timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in }
var body: some View {
VStack {
Button {
showDot.toggle()
} label: {
Text("Toggle Show Dot")
}
Spacer()
.frame(height: pos)
Circle().stroke()
.frame(height: 50)
.overlay {
if showDot {
Circle().frame(height: 20)
}
}
Spacer()
}
.onAppear {
// The timer interval will define the speed
timer = Timer.scheduledTimer(withTimeInterval: 0.02, repeats: true) { _ in
moveCircle()
}
}
}
private func moveCircle() {
if movingUp {
if pos <= 0 {
pos = 0
movingUp = false
} else {
pos -= 1
}
} else {
if pos >= 100 {
pos = 100
movingUp = true
} else {
pos += 1
}
}
}

Capture video : SwiftUI

I want to capture a video through back camera using swiftUI. I can not find the proper solution on on video recording. I implement the code that record video automatically when view is open But I want to start the recording on bottom button click. Can someone please guide me on this.
import SwiftUI
import AVKit
struct RecordingView: View {
#State private var timer = 5
#State private var onComplete = false
#State private var recording = false
var body: some View {
ZStack {
VideoRecordingView(timeLeft: $timer, onComplete: $onComplete, recording: $recording)
VStack {
Button(action: {self.recording.toggle()}, label: {
ZStack {
Circle()
.fill(Color.white)
.frame(width: 65, height: 65)
Circle()
.stroke(Color.white,lineWidth: 2)
.frame(width: 75, height: 75)
}
})
Button(action: {
self.timer -= 1
print(self.timer)
}, label: {
Text("Toggle timer")
})
.foregroundColor(.white)
.padding()
Button(action: {
self.onComplete.toggle()
}, label: {
Text("Toggle completion")
})
.foregroundColor(.white)
.padding()
}
}
}
}
This is For recordingView
struct VideoRecordingView: UIViewRepresentable {
#Binding var timeLeft: Int
#Binding var onComplete: Bool
#Binding var recording: Bool
func makeUIView(context: UIViewRepresentableContext<VideoRecordingView>) -> PreviewView {
let recordingView = PreviewView()
recordingView.onComplete = {
self.onComplete = true
}
recordingView.onRecord = { timeLeft, totalShakes in
self.timeLeft = timeLeft
self.recording = true
}
recordingView.onReset = {
self.recording = false
self.timeLeft = 30
}
return recordingView
}
func updateUIView(_ uiViewController: PreviewView, context: UIViewRepresentableContext<VideoRecordingView>) {
}
}
extension PreviewView: AVCaptureFileOutputRecordingDelegate{
func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) {
print(outputFileURL.absoluteString)
}
}
class PreviewView: UIView {
private var captureSession: AVCaptureSession?
private var shakeCountDown: Timer?
let videoFileOutput = AVCaptureMovieFileOutput()
var recordingDelegate:AVCaptureFileOutputRecordingDelegate!
var recorded = 0
var secondsToReachGoal = 30
var onRecord: ((Int, Int)->())?
var onReset: (() -> ())?
var onComplete: (() -> ())?
init() {
super.init(frame: .zero)
var allowedAccess = false
let blocker = DispatchGroup()
blocker.enter()
AVCaptureDevice.requestAccess(for: .video) { flag in
allowedAccess = flag
blocker.leave()
}
blocker.wait()
if !allowedAccess {
print("!!! NO ACCESS TO CAMERA")
return
}
// setup session
let session = AVCaptureSession()
session.beginConfiguration()
let videoDevice = AVCaptureDevice.default(.builtInWideAngleCamera,
for: .video, position: .front)
guard videoDevice != nil, let videoDeviceInput = try? AVCaptureDeviceInput(device: videoDevice!), session.canAddInput(videoDeviceInput) else {
print("!!! NO CAMERA DETECTED")
return
}
session.addInput(videoDeviceInput)
session.commitConfiguration()
self.captureSession = session
}
override class var layerClass: AnyClass {
AVCaptureVideoPreviewLayer.self
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
var videoPreviewLayer: AVCaptureVideoPreviewLayer {
return layer as! AVCaptureVideoPreviewLayer
}
override func didMoveToSuperview() {
super.didMoveToSuperview()
recordingDelegate = self
startTimers()
if nil != self.superview {
self.videoPreviewLayer.session = self.captureSession
self.videoPreviewLayer.videoGravity = .resizeAspect
self.captureSession?.startRunning()
self.startRecording()
} else {
self.captureSession?.stopRunning()
}
}
private func onTimerFires(){
print("🟢 RECORDING \(videoFileOutput.isRecording)")
secondsToReachGoal -= 1
recorded += 1
onRecord?(secondsToReachGoal, recorded)
if(secondsToReachGoal == 0){
stopRecording()
shakeCountDown?.invalidate()
shakeCountDown = nil
onComplete?()
videoFileOutput.stopRecording()
}
}
func startTimers(){
if shakeCountDown == nil {
shakeCountDown = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] (timer) in
self?.onTimerFires()
}
}
}
func startRecording(){
captureSession?.addOutput(videoFileOutput)
let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
let filePath = documentsURL.appendingPathComponent("tempPZDC")
videoFileOutput.startRecording(to: filePath, recordingDelegate: recordingDelegate)
}
func stopRecording(){
videoFileOutput.stopRecording()
print("🔴 RECORDING \(videoFileOutput.isRecording)")
}
}
Modify your code by this
struct VideoRecordingView: UIViewRepresentable {
#Binding var timeLeft: Int
#Binding var onComplete: Bool
#Binding var recording: Bool
func makeUIView(context: UIViewRepresentableContext<VideoRecordingView>) -> PreviewView {
let recordingView = PreviewView()
recordingView.onComplete = {
self.onComplete = true
}
recordingView.onRecord = { timeLeft, totalShakes in
self.timeLeft = timeLeft
self.recording = true
}
recordingView.onReset = {
self.recording = false
self.timeLeft = 30
}
return recordingView
}
func updateUIView(_ uiViewController: PreviewView, context: UIViewRepresentableContext<VideoRecordingView>) {
if recording {
uiViewController.start()
}
}
}
extension PreviewView: AVCaptureFileOutputRecordingDelegate{
func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) {
print(outputFileURL.absoluteString)
}
}
class PreviewView: UIView {
private var captureSession: AVCaptureSession?
private var shakeCountDown: Timer?
let videoFileOutput = AVCaptureMovieFileOutput()
var recordingDelegate:AVCaptureFileOutputRecordingDelegate!
var recorded = 0
var secondsToReachGoal = 30
var onRecord: ((Int, Int)->())?
var onReset: (() -> ())?
var onComplete: (() -> ())?
init() {
super.init(frame: .zero)
var allowedAccess = false
let blocker = DispatchGroup()
blocker.enter()
AVCaptureDevice.requestAccess(for: .video) { flag in
allowedAccess = flag
blocker.leave()
}
blocker.wait()
if !allowedAccess {
print("!!! NO ACCESS TO CAMERA")
return
}
// setup session
let session = AVCaptureSession()
session.beginConfiguration()
let videoDevice = AVCaptureDevice.default(.builtInWideAngleCamera,
for: .video, position: .front)
guard videoDevice != nil, let videoDeviceInput = try? AVCaptureDeviceInput(device: videoDevice!), session.canAddInput(videoDeviceInput) else {
print("!!! NO CAMERA DETECTED")
return
}
session.addInput(videoDeviceInput)
session.commitConfiguration()
self.captureSession = session
}
override class var layerClass: AnyClass {
AVCaptureVideoPreviewLayer.self
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
var videoPreviewLayer: AVCaptureVideoPreviewLayer {
return layer as! AVCaptureVideoPreviewLayer
}
override func didMoveToSuperview() {
super.didMoveToSuperview()
recordingDelegate = self
}
func start() {
startTimers()
if nil != self.superview {
self.videoPreviewLayer.session = self.captureSession
self.videoPreviewLayer.videoGravity = .resizeAspect
self.captureSession?.startRunning()
self.startRecording()
} else {
self.captureSession?.stopRunning()
}
}
private func onTimerFires(){
print("🟢 RECORDING \(videoFileOutput.isRecording)")
secondsToReachGoal -= 1
recorded += 1
onRecord?(secondsToReachGoal, recorded)
if(secondsToReachGoal == 0){
stopRecording()
shakeCountDown?.invalidate()
shakeCountDown = nil
onComplete?()
videoFileOutput.stopRecording()
}
}
func startTimers(){
if shakeCountDown == nil {
shakeCountDown = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] (timer) in
self?.onTimerFires()
}
}
}
func startRecording(){
captureSession?.addOutput(videoFileOutput)
let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
let filePath = documentsURL.appendingPathComponent("tempPZDC")
videoFileOutput.startRecording(to: filePath, recordingDelegate: recordingDelegate)
}
func stopRecording(){
videoFileOutput.stopRecording()
print("🔴 RECORDING \(videoFileOutput.isRecording)")
}
}

Is there a better way to implement a shake animation in swiftui?

I'm trying to get a button to shake when the user tries to log in without filling all the textfields in, and this is what I've come across so far:
struct Shake: GeometryEffect {
var amount: CGFloat = 10
var shakesPerUnit = 3
var animatableData: CGFloat
func effectValue(size: CGSize) -> ProjectionTransform {
ProjectionTransform(CGAffineTransform(translationX:
amount * sin(animatableData * .pi * CGFloat(shakesPerUnit)),
y: 0))
}
}
struct Correct: View {
#State var attempts: Int = 0
var body: some View {
VStack {
Rectangle()
.fill(Color.pink)
.frame(width: 200, height: 100)
.modifier(Shake(animatableData: CGFloat(attempts)))
Spacer()
Button(action: {
withAnimation(.default) {
self.attempts += 1
}
}, label: { Text("Login") })
}
}
}
However, this is particularly useless for a button, and even then the animation seems very off in that its pretty robotic. Can someone suggest an improvement so that I can get my button to shake?
try this
struct ContentView: View {
#State var selected = false
var body: some View {
VStack {
Button(action: {
self.selected.toggle()
}) { selected ? Text("Deselect") : Text("Select") }
Rectangle()
.fill(Color.purple)
.frame(width: 200, height: 200)
.offset(x: selected ? -30 : 0)
.animation(Animation.default.repeatCount(5).speed(6))
}
}
}
I do this to make the field shake and then gets back to it's original position:
private func signUp() {
if email.isEmpty {
withAnimation {
emailIsWrong = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
withAnimation {
emailIsWrong = false
}
}
return
}
}
Where emailIsWrong is a #State variable:
#State private var emailIsWrong = false
Basically after 0.2 sec, I change the emailIsWrong back to false so the view goes back to its position. My text field looks like this:
TextField("Email", text: $email)
.padding()
.frame(height: 45)
.background(Color.white)
.colorScheme(.light)
.offset(x: emailIsWrong ? -8 : 0)
.animation(Animation.default.repeatCount(3, autoreverses: true).speed(6))
A little bit late to the party, but unfortunately the solutions here either finish the animation with a wrong offset or need some hardcoded assumption on the time the animation will finish.
The solution I came up with looks like this:
#State var shake = false
Text("Shake Me")
.font(.title)
.onTapGesture {
shake = true
}
.shake($shake) {
print("Finished")
}
To animate, you just need to set shake to true (it will automatically be set to false once the animation completes).
Here is the implementation:
struct Shake<Content: View>: View {
/// Set to true in order to animate
#Binding var shake: Bool
/// How many times the content will animate back and forth
var repeatCount = 3
/// Duration in seconds
var duration = 0.8
/// Range in pixels to go back and forth
var offsetRange = 10.0
#ViewBuilder let content: Content
var onCompletion: (() -> Void)?
#State private var xOffset = 0.0
var body: some View {
content
.offset(x: xOffset)
.onChange(of: shake) { shouldShake in
guard shouldShake else { return }
Task {
let start = Date()
await animate()
let end = Date()
print(end.timeIntervalSince1970 - start.timeIntervalSince1970)
shake = false
onCompletion?()
}
}
}
// Obs: some of factors must be 1.0.
private func animate() async {
let factor1 = 0.9
let eachDuration = duration * factor1 / CGFloat(repeatCount)
for _ in 0..<repeatCount {
await backAndForthAnimation(duration: eachDuration, offset: offsetRange)
}
let factor2 = 0.1
await animate(duration: duration * factor2) {
xOffset = 0.0
}
}
private func backAndForthAnimation(duration: CGFloat, offset: CGFloat) async {
let halfDuration = duration / 2
await animate(duration: halfDuration) {
self.xOffset = offset
}
await animate(duration: halfDuration) {
self.xOffset = -offset
}
}
}
extension View {
func shake(_ shake: Binding<Bool>,
repeatCount: Int = 3,
duration: CGFloat = 0.8,
offsetRange: CGFloat = 10,
onCompletion: (() -> Void)? = nil) -> some View {
Shake(shake: shake,
repeatCount: repeatCount,
duration: duration,
offsetRange: offsetRange) {
self
} onCompletion: {
onCompletion?()
}
}
func animate(duration: CGFloat, _ execute: #escaping () -> Void) async {
await withCheckedContinuation { continuation in
withAnimation(.linear(duration: duration)) {
execute()
}
DispatchQueue.main.asyncAfter(deadline: .now() + duration) {
continuation.resume()
}
}
}
}
here's my one
#State var ringOnFinish: Bool = false
#State var shakeOffset: Double = 0
Button() {
ringOnFinish.toggle()
//give it a little shake animation when off
if !ringOnFinish {
shakeOffset = 5
withAnimation {
shakeOffset = 0
}
} label: {
Image(systemName: "bell\(ringOnFinish ? "" : ".slash")")
.offset(x: ringOnFinish ? 0 : shakeOffset)
.animation(.default.repeatCount(3, autoreverses: true).speed(6), value: ringOnFinish)
}

how to do a simple scaling animation and why isn't this working?

i just read in stackoverflow i can only concatenate animation with delay, so i tried this here which simply shrinks and then scales the circle again. unfortunately the shrinking doesn't work!? if i comment out the growing, shrinking works...
struct ContentView: View {
#State var scaleImage : CGFloat = 1
var body: some View {
VStack {
Button(action: {
withAnimation(Animation.easeInOut(duration: 1)) {
self.scaleImage = 0.01
}
withAnimation(Animation.easeInOut(duration: 1).delay(1.0)) {
self.scaleImage = 1
}
}) {
Text ("Start animation")
}
Image(systemName: "circle.fill")
.scaleEffect(scaleImage)
}
}
}
Here is possible approach (based on AnimatableModifier). Actually it demonstrates how current animation end can be detected, and performed something - in this case, for your scaling scenario, just initiate reversing.
Simplified & modified your example
struct TestReversingScaleAnimation: View {
#State var scaleImage : CGFloat = 1
var body: some View {
VStack {
Button("Start animation") {
self.scaleImage = 0.01 // initiate animation
}
Image(systemName: "circle.fill")
.modifier(ReversingScale(to: scaleImage) {
self.scaleImage = 1 // reverse set
})
.animation(.default) // now can be implicit
}
}
}
Actually, show-maker here... important comments inline.
Updated for Xcode 13.3 (tested with iOS 15.4)
struct ReversingScale: AnimatableModifier {
var value: CGFloat
private let target: CGFloat
private let onEnded: () -> ()
init(to value: CGFloat, onEnded: #escaping () -> () = {}) {
self.target = value
self.value = value
self.onEnded = onEnded // << callback
}
var animatableData: CGFloat {
get { value }
set { value = newValue
// newValue here is interpolating by engine, so changing
// from previous to initially set, so when they got equal
// animation ended
let callback = onEnded
if newValue == target {
DispatchQueue.main.async(execute: callback)
}
}
}
func body(content: Content) -> some View {
content.scaleEffect(value)
}
}
Original variant (tested with Xcode 11.4 / iOS 13.4)
struct ReversingScale: AnimatableModifier {
var value: CGFloat
private var target: CGFloat
private var onEnded: () -> ()
init(to value: CGFloat, onEnded: #escaping () -> () = {}) {
self.target = value
self.value = value
self.onEnded = onEnded // << callback
}
var animatableData: CGFloat {
get { value }
set { value = newValue
// newValue here is interpolating by engine, so changing
// from previous to initially set, so when they got equal
// animation ended
if newValue == target {
onEnded()
}
}
}
func body(content: Content) -> some View {
content.scaleEffect(value)
}
}

SwiftUI: How it refreshes view and why #Published ObservableObject properties works randomly

SwiftUI seems to me more and more confusing in the way it works.
At first glance it seams fast and easy to grasp. But if you add more and more views
something that seems to be simple starts to behave very odd and take many time to solve.
I have Input field with validation. This is customized input with that I can reuse in many places. But on different screens this can work totally different and totally unreliable.
View with form
struct LoginView {
#ObservedObject private var viewModel = LoginViewModel()
var body: some View {
VStack(spacing: 32) {
Spacer()
LabeledInput(label: "Email", input: self.$viewModel.email, isNuemorphic: true, rules: LoginFormRules.email, validation: self.$viewModel.emailValidation)
.textContentType(.emailAddress)
.keyboardType(.emailAddress)
.autocapitalization(.none)
.frame(height: 50)
LabeledInput(label: "Password", isSecure: true, input: self.$viewModel.password, isNuemorphic: true, rules: LoginFormRules.password, validation: self.$viewModel.passwordValidation)
.textContentType(.password)
.keyboardType(.asciiCapable)
.autocapitalization(.none)
.frame(height: 50)
self.makeSubmitButton()
Spacer()
}
}
LabeledInput - resuable custom input view with validation support
struct LabeledInput: View {
// MARK: - Properties
let label: String?
let isSecure: Bool
// MARK: - Binding
#Binding var input: String
var isEditing: Binding<Bool>?
// MARK: - Actions
private let onEditingChanged: (Bool) -> Void
private let onCommit: () -> Void
// MARK: - Validation
#ObservedObject var validator: FieldValidator<String>
// MARK: - Init
init(label: String? = nil,
isSecure: Bool = false,
input: Binding<String>,
isEditing: Binding<Bool>? = nil,
// validation
rules: [Rule<String>] = [],
validation: Binding<Validation>? = nil,
// actions
onEditingChanged: #escaping (Bool) -> Void = { _ in },
onCommit: #escaping () -> Void = { }) {
self.label = label
self.isSecure = isSecure
self._input = input
self.isEditing = isEditing
self.onEditingChanged = onEditingChanged
self.onCommit = onCommit
self.validator = FieldValidator(input: input, rules: rules, validation: validation ?? .constant(Validation()))
}
var useUIKit: Bool {
self.isEditing != nil
}
var body: some View {
GeometryReader { geometry in
ZStack {
RoundedRectangle(cornerRadius: 4.0)
.stroke(lineWidth: 1)
.foregroundColor(!self.validator.validation.isEdited ? Color("LightGray")
: self.validator.validation.isValid ? Color("Green") : Color("Red"))
.frame(maxHeight: geometry.size.height)
.offset(x: 0, y: 16)
VStack {
HStack {
self.makeLabel()
.offset(x: self.isNuemorphic ? 0 : 16,
y: self.isNuemorphic ? 0 : 8)
Spacer()
}
Spacer()
}
self.makeField()
.frame(maxHeight: geometry.size.height)
.offset(x: 0, y: self.isNuemorphic ? 20 : 16)
.padding(10)
}
}
}
private func makeField() -> some View {
Group {
if useUIKit {
self.makeUIKitTextField(secure: self.isSecure)
} else {
if self.isSecure {
self.makeSecureField()
} else {
self.makeTextField()
}
}
}
}
private func makeLabel() -> some View {
Group {
if label != nil {
Text("\(self.label!.uppercased())")
.font(.custom("AvenirNext-Regular", size: self.isNuemorphic ? 13 : 11))
.foregroundColor(!self.validator.validation.isEdited ? Color("DarkBody")
: self.validator.validation.isValid ? Color("Green") : Color("Red"))
.padding(.horizontal, 8)
} else {
EmptyView()
}
}
}
private func makeSecureField() -> some View {
SecureField("", text: self.$input, onCommit: {
self.validator.onCommit()
self.onCommit()
})
.font(.custom("AvenirNext-Regular", size: 15))
.foregroundColor(Color("DarkBody"))
.frame(maxWidth: .infinity)
}
private func makeTextField() -> some View {
TextField("", text: self.$input, onEditingChanged: { editing in
self.onEditingChanged(editing)
self.validator.onEditing(editing)
if !editing { self.onCommit() }
}, onCommit: {
self.validator.onCommit()
self.onCommit()
})
.font(.custom("AvenirNext-Regular", size: 15))
.foregroundColor(Color("DarkBody"))
.frame(maxWidth: .infinity)
}
private func makeUIKitTextField(secure: Bool) -> some View {
let firstResponderBinding = Binding<Bool>(get: {
self.isEditing?.wrappedValue ?? false //?? self.isFirstResponder
}, set: {
//self.isFirstResponder = $0
self.isEditing?.wrappedValue = $0
})
return UIKitTextField(text: self.$input, isEditing: firstResponderBinding, font: UIFont(name: "AvenirNext-Regular", size: 15)!, textColor: UIColor(named: "DarkBody")!, placeholder: "", onEditingChanged: { editing in
self.onEditingChanged(editing)
self.validator.onEditing(editing)
}, onCommit: {
self.validator.onCommit()
self.onCommit()
})
}
}
And here is how I store model (input values and validation) in ObservableObject i.e. LoginViewModel.
final class LoginViewModel: ObservableObject {
// MARK: - Published
#Published var email: String = ""
#Published var password: String = ""
#Published var emailValidation: Validation = Validation(onEditing: true)
#Published var passwordValidation: Validation = Validation(onEditing: true)
#Published var validationErrors: [String]? = nil
#Published var error: DescribableError? = nil
}
When I use this code depending on how I create ViewModel (in LoginView property or injected to LoginView constructor) depending on view parent views (screens) it is embedded can work totally different can cause hours of debugging and unexpected behaviour.
Sometimes it seems that there is 1 ViewModel instance sometimes it seems that this instance is created with each View refresh
sometimes LabeledInput body is refreshing and validation colouring of label works corretly. Other times it seems it does not refresh at all and nothing happens
sometimes refreshes so often keyboard is immediately hiding
Other times there is no validation at all
Other times input is lost after exiting field or when rotating phone landscape to portrait
If there is some event that causes parent view refresh it can cause the inputs to lose data and validation.
Sometimes it refreshes to often other times it doesn't refresh at all as it should.
I've tried to add .id(UUID) , custom .id(refreshId) or other Equatable protocol implementations but it doesn't work as expected to be reusable customized input with validation reusable between multiple forms on multiple screens.
Here is simple validation struct
struct Validation {
let onEditing: Bool
init(onEditing: Bool = false) {
self.onEditing = onEditing
}
var isEdited: Bool = false
var errors: [String] = []
}
And here FieldValidator ObservableObject
class FieldValidator<T>: ObservableObject {
// MARK: - Properties
private let rules: [Rule<T>]
// MARK: - Binding
#Binding private var input: T
#Binding var validation: Validation
// MARK: - Init
init(input: Binding<T>, rules: [Rule<T>], validation: Binding<Validation>) {
#if DEBUG
print("[FieldValidator] init: \(input.wrappedValue)")
#endif
self._input = input
self.rules = rules
self._validation = validation
}
private var disposables = Set<AnyCancellable>()
}
// MARK: - Public API
extension FieldValidator {
func validateField() {
validation.errors = rules
.filter { !$0.isAsync }
.filter { !$0.validate(input) }
.map { $0.errorMessage() }
}
func validateFieldAsync() {
rules
.filter { $0.isAsync }
.forEach { rule in
rule.validateAsync(input)
.filter { valid in
!valid
}.sink(receiveValue: { _ in
self.validation.errors.append(rule.errorMessage())
})
.store(in: &disposables)
}
}
}
// MARK: - Helper Public API
extension FieldValidator {
func onEditing(_ editing: Bool) {
self.validation.isEdited = true
if editing {
if self.validation.onEditing {
self.validateField()
}
} else {
// on end editing
self.validateField()
self.validateFieldAsync()
}
}
func onCommit() {
self.validateField()
self.validateFieldAsync()
}
}
Rules are just subclasses of
class Rule<T> {
var isAsync: Bool { return false }
func validate(_ value: T) -> Bool { return false }
func errorMessage() -> String { return "" }
func validateAsync(_ value: T) -> AnyPublisher<Bool, Never> {
fatalError("Async validation is not implemented!")
}
}
UPDATE
Complete UIKitTextField example
#available(iOS 13.0, *)
struct UIKitTextField: UIViewRepresentable {
// MARK: - Observed
#ObservedObject private var keyboardEvents = KeyboardEvents()
// MARK: - Binding
#Binding var text: String
var isEditing: Binding<Bool>?
// MARK: - Actions
let onBeginEditing: () -> Void
let onEndEditing: () -> Void
let onEditingChanged: (Bool) -> Void
let onCommit: () -> Void
// MARK: - Proprerties
private let keyboardOffset: CGFloat
private let textAlignment: NSTextAlignment
private let font: UIFont
private let textColor: UIColor
private let backgroundColor: UIColor
private let contentType: UITextContentType?
private let keyboardType: UIKeyboardType
private let autocorrection: UITextAutocorrectionType
private let autocapitalization: UITextAutocapitalizationType
private let isSecure: Bool
private let isUserInteractionEnabled: Bool
private let placeholder: String?
public static let defaultFont = UIFont.preferredFont(forTextStyle: .body)
private var hasDoneToolbar: Bool = false
init(text: Binding<String>,
isEditing: Binding<Bool>? = nil,
keyboardOffset: CGFloat = 0,
textAlignment: NSTextAlignment = .left,
font: UIFont = UIKitTextField.defaultFont,
textColor: UIColor = .black,
backgroundColor: UIColor = .white,
contentType: UITextContentType? = nil,
keyboardType: UIKeyboardType = .default,
autocorrection: UITextAutocorrectionType = .default,
autocapitalization: UITextAutocapitalizationType = .none,
isSecure: Bool = false,
isUserInteractionEnabled: Bool = true,
placeholder: String? = nil,
hasDoneToolbar: Bool = false,
onBeginEditing: #escaping () -> Void = { },
onEndEditing: #escaping () -> Void = { },
onEditingChanged: #escaping (Bool) -> Void = { _ in },
onCommit: #escaping () -> Void = { }) {
self._text = text
self.isEditing = isEditing
self.keyboardOffset = keyboardOffset
self.onBeginEditing = onBeginEditing
self.onEndEditing = onEndEditing
self.onEditingChanged = onEditingChanged
self.onCommit = onCommit
self.textAlignment = textAlignment
self.font = font
self.textColor = textColor
self.backgroundColor = backgroundColor
self.contentType = contentType
self.keyboardType = keyboardType
self.autocorrection = autocorrection
self.autocapitalization = autocapitalization
self.isSecure = isSecure
self.isUserInteractionEnabled = isUserInteractionEnabled
self.placeholder = placeholder
self.hasDoneToolbar = hasDoneToolbar
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIView(context: Context) -> UITextField {
let textField = UITextField()
textField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
textField.delegate = context.coordinator
textField.keyboardType = keyboardType
textField.textAlignment = textAlignment
textField.font = font
textField.textColor = textColor
textField.backgroundColor = backgroundColor
textField.textContentType = contentType
textField.autocorrectionType = autocorrection
textField.autocapitalizationType = autocapitalization
textField.isSecureTextEntry = isSecure
textField.isUserInteractionEnabled = isUserInteractionEnabled
//textField.placeholder = placeholder
if let placeholder = placeholder {
textField.attributedPlaceholder = NSAttributedString(
string: placeholder,
attributes: [
NSAttributedString.Key.foregroundColor: UIColor.lightGray
])
}
textField.addTarget(context.coordinator, action: #selector(Coordinator.valueChanged(_:)), for: .editingChanged)
keyboardEvents.didShow = {
if textField.isFirstResponder {
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(350)) {
textField.adjustScrollView(offset: self.keyboardOffset, animated: true)
}
}
}
if hasDoneToolbar {
textField.addDoneButton {
print("Did tap Done Toolbar button")
textField.resignFirstResponder()
}
}
return textField
}
func updateUIView(_ textField: UITextField, context: Context) {
textField.text = text
if let isEditing = isEditing {
if isEditing.wrappedValue {
textField.becomeFirstResponder()
} else {
textField.resignFirstResponder()
}
}
}
final class Coordinator: NSObject, UITextFieldDelegate {
let parent: UIKitTextField
init(_ parent: UIKitTextField) {
self.parent = parent
}
#objc func valueChanged(_ textField: UITextField) {
parent.text = textField.text ?? ""
parent.onEditingChanged(true)
}
func textFieldDidBeginEditing(_ textField: UITextField) {
parent.onBeginEditing()
parent.onEditingChanged(true)
}
func textFieldDidEndEditing(_ textField: UITextField, reason: UITextField.DidEndEditingReason) {
//guard textField.text != "" || parent.shouldCommitIfEmpty else { return }
DispatchQueue.main.async {
self.parent.isEditing?.wrappedValue = false
}
parent.text = textField.text ?? ""
parent.onEditingChanged(false)
parent.onEndEditing()
parent.onCommit()
}
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
parent.isEditing?.wrappedValue = false
textField.resignFirstResponder()
parent.onCommit()
return true
}
}
}
extension UIView {
func adjustScrollView(offset: CGFloat, animated: Bool = false) {
if let scrollView = findParent(of: UIScrollView.self) {
let contentOffset = CGPoint(x: scrollView.contentOffset.x, y: scrollView.contentOffset.y + offset)
scrollView.setContentOffset(contentOffset, animated: animated)
} else {
print("View is not in ScrollView - do not adjust content offset")
}
}
}
Here is sample EmailRule implementation
class EmailRule : RegexRule {
static let regex = "[A-Z0-9a-z._%+-]+#[A-Za-z0-9.-]+\\.[A-Za-z]{2,}"
public convenience init(message : String = "Email address is invalid"){
self.init(regex: EmailRule.regex, message: message)
}
override func validate(_ value: String) -> Bool {
guard value.count > 0 else { return true }
return super.validate(value)
}
}

Resources