I have UIPageViewController with 5 pages. It's on boarding animated UIViewControllers animations on each page. I researched for days and couldn't find the way to stop animations and reset the page after scroll(page change) occurs.
My UIPageViewController is set up like this:
class BoardingPageViewController: UIPageViewController {
override func viewDidLoad() {
super.viewDidLoad()
dataSource = self
delegate = self
setViewControllers([getStepOne()], direction: .forward, animated: false, completion: nil)
}
override var preferredStatusBarStyle: UIStatusBarStyle {
return .lightContent
}
func getStepOne() -> BoardView1ViewController {
return storyboard!.instantiateViewController(withIdentifier: "BoardView1") as! BoardView1ViewController
}
func getStepTwo() -> BoardView2ViewController {
return storyboard!.instantiateViewController(withIdentifier: "BoardView2") as! BoardView2ViewController
}
func getStepThree() -> BoardView3ViewController {
return storyboard!.instantiateViewController(withIdentifier: "BoardView3") as! BoardView3ViewController
}
func getStepFour() -> BoardView4ViewController {
return storyboard!.instantiateViewController(withIdentifier: "BoardView4") as! BoardView4ViewController
}
func getStepFive() -> BoardView5ViewController {
return storyboard!.instantiateViewController(withIdentifier: "BoardView5") as! BoardView5ViewController
}
}
extension BoardingPageViewController : UIPageViewControllerDataSource {
func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
if viewController.isKind(of: BoardView5ViewController.self) {
// 5 -> 4
return getStepFour()
} else if viewController.isKind(of: BoardView4ViewController.self) {
// 4 -> 3
return getStepThree()
} else if viewController.isKind(of: BoardView3ViewController.self) {
// 3 -> 2
return getStepTwo()
} else if viewController.isKind(of: BoardView2ViewController.self) {
// 2 -> 1
return getStepOne()
} else {
// 0 -> end of the road
return nil
}
}
func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
if viewController.isKind(of: BoardView1ViewController.self) {
// 0 -> 1
return getStepTwo()
} else if viewController.isKind(of: BoardView2ViewController.self) {
// 1 -> 2
return getStepThree()
} else if viewController.isKind(of: BoardView3ViewController.self) {
// 1 -> 2
return getStepFour()
} else if viewController.isKind(of: BoardView4ViewController.self) {
// 1 -> 2
return getStepFive()
} else {
// 2 -> end of the road
return nil
}
}
func presentationCount(for pageViewController: UIPageViewController) -> Int {
return 5
}
func presentationIndex(for pageViewController: UIPageViewController) -> Int {
return 0
}
}
extension BoardingPageViewController : UIPageViewControllerDelegate {
}
And my UIViewControllers are all similar with simple animations. I tried to making this happen with having chainAni value switch to false and call it on ViewDidDisappear. But it doesn't work if page switch happens in the middle of animation.
override func viewDidAppear(_ animated: Bool) {
chainAni = true
ani0()
}
func ani0() {
if chainAni != true {
return
}
UIView.animate(withDuration: 1, delay: 1, animations: {
self.handPic.alpha = 1
}) { (false) in
self.ani1()
}
}
func ani1() {
if chainAni != true {
return
}
UIView.animate(withDuration: 1.5, delay: 1, usingSpringWithDamping: 0.5, initialSpringVelocity: 0, options: [], animations: {
self.highlightAction(any: self.handPic)
self.highlightAction(any: self.folderBtn)
}) { (false) in
self.ani2()
}
}
func ani2() {
if chainAni != true {
return
}
UIView.animate(withDuration: 1.5, delay: 1, animations: {
self.unhighlightAction(any: self.handPic)
self.unhighlightAction(any: self.folderBtn)
}) { (false) in
self.ani3()
}
}
func ani3() {
if chainAni != true {
return
}
UIView.animate(withDuration: 0.5, delay: 0, animations: {
self.handPic.alpha = 0
self.alertImg.alpha = 1
self.alertImg.transform = .identity
}) { (false) in
self.ani4()
}
}
func clearAni() {
alertImg.image = #imageLiteral(resourceName: "alert1")
darkView.removeFromSuperview()
screenView.removeFromSuperview()
mainImg.alpha = 1
alertImg.alpha = 0
alert2Img.alpha = 0
handPic.alpha = 0
handPic.transform = .identity
hand2Pic.alpha = 0
hand2Pic.transform = .identity
newFolderImg.alpha = 0
newFolderImg.transform = .identity
}
override func viewDidDisappear(_ animated: Bool) {
clearAni()
chainAni = false
}
So, I figured I have to remove every animation from it's superview layer.
func clearAni() {
alertImg.image = #imageLiteral(resourceName: "alert1")
mainImg.layer.removeAllAnimations()
mainLbl.layer.removeAllAnimations()
alertImg.layer.removeAllAnimations()
alert2Img.layer.removeAllAnimations()
folderBtn.layer.removeAllAnimations()
handPic.layer.removeAllAnimations()
hand2Pic.layer.removeAllAnimations()
darkView.layer.removeAllAnimations()
mainImg.alpha = 1
alertImg.alpha = 0
alert2Img.alpha = 0
handPic.alpha = 0
handPic.transform = .identity
hand2Pic.alpha = 0
hand2Pic.transform = .identity
newFolderImg.alpha = 0
newFolderImg.transform = .identity
}
Related
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.
I have a customized text field that allows me to write multiple lines of text. However, I am wondering if there is a way to limit how many lines this text field will allow you to write. Setting a frame doesn't seem to work, as once you pass the frame's limit, you just continue to scroll down new lines. Something like TikTok or Instagram's edit bio forms, which after a certain amount of returns, you can't go any further.
Here is what I have so far:
struct NewSnippetTextEditor : UIViewRepresentable {
#Binding var txt : String
var doppleGray = Color(hue: 1.0, saturation: 0.058, brightness: 0.396)
#StateObject var profileData = ProfileViewModel()
func makeCoordinator() -> NewSnippetTextEditor.NewSnippetCoordinator {
return NewSnippetTextEditor.NewSnippetCoordinator(parent1: self)
}
func makeUIView(context: UIViewRepresentableContext<NewSnippetTextEditor>) -> UITextView {
let tview = UITextView()
tview.isEditable = true
tview.isUserInteractionEnabled = true
tview.isScrollEnabled = true
tview.text = "What's up..."
tview.textColor = .gray
tview.font = .systemFont(ofSize: 18)
tview.keyboardType = .twitter
tview.delegate = context.coordinator
return tview
}
func updateUIView(_ uiView: UITextView, context: UIViewRepresentableContext<NewSnippetTextEditor>) {
}
class NewSnippetCoordinator : NSObject, UITextViewDelegate{
var parent : NewSnippetTextEditor
init(parent1 : NewSnippetTextEditor) {
parent = parent1
}
func textViewDidChange(_ textView: UITextView) {
self.parent.txt = textView.text
}
func textViewDidBeginEditing(_ textView: UITextView) {
textView.text = ""
textView.textColor = .label
}
}
}
And then to display this in a view I use:
NewSnippetTextEditor(txt: $newSnippetData.snippetTxt)
.cornerRadius(20)
.padding(EdgeInsets.init(top: 0, leading: 20, bottom: 0, trailing: 40))
.opacity(newSnippetData.isPosting ? 0.5 : 1)
.disabled(newSnippetData.isPosting ? true : false)
If this doesn't work for you, I also have another version that you might be able to better tweak.
struct NewSnippetTextView: UIViewRepresentable{
#Binding var text:String
#Binding var height:CGFloat
var placeholderText: String
#State var editing:Bool = false
func makeUIView(context: Context) -> UITextView {
let textView = UITextView()
textView.isEditable = true
textView.isScrollEnabled = true
textView.text = placeholderText
textView.delegate = context.coordinator
textView.textColor = UIColor.white
textView.backgroundColor = UIColor.black
textView.font = UIFont.systemFont(ofSize: 18)
textView.keyboardType = .twitter
return textView
}
func updateUIView(_ textView: UITextView, context: Context) {
if self.text.isEmpty == true{
textView.text = self.editing ? "" : self.placeholderText
textView.textColor = self.editing ? .white : .lightGray
}
DispatchQueue.main.async {
self.height = textView.contentSize.height
textView.textContainerInset = UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10)
}
}
func makeCoordinator() -> Coordinator {
NewSnippetTextView.Coordinator(self)
}
class Coordinator: NSObject, UITextViewDelegate{
var parent: NewSnippetTextView
init(_ params: NewSnippetTextView) {
self.parent = params
}
func textViewDidBeginEditing(_ textView: UITextView) {
DispatchQueue.main.async {
self.parent.editing = true
}
}
func textViewDidEndEditing(_ textView: UITextView) {
DispatchQueue.main.async {
self.parent.editing = false
}
}
func textViewDidChange(_ textView: UITextView) {
DispatchQueue.main.async {
self.parent.height = textView.contentSize.height
self.parent.text = textView.text
}
}
}
}
To display this second version I used:
NewSnippetTextView(text: $newSnippetData.snippetTxt, height: $textViewHeight, placeholderText: "What's up")
.frame(height: textViewHeight < 160 ? self.textViewHeight : 160)
.cornerRadius(20)
.padding(EdgeInsets.init(top: 0, leading: 20, bottom: 0, trailing: 40))
.opacity(newSnippetData.isPosting ? 0.5 : 1)
.disabled(newSnippetData.isPosting ? true : false)
How about using a standard TextEditor and checking its length at change:
(I admit it only counts characters, not lines, but you could implement that too by counting line breaks)
TextEditor(text: $textInput)
.onChange(of: textInput) { newValue in
if newValue.count > 30 {
textInput = String(newValue.dropLast(1))
}
}
Here is my view model:
let loadMoreTrigger = PublishSubject<Void>()
let refreshTrigger = PublishSubject<Void>()
let loading = BehaviorRelay<Bool>(value: false)
let stories = BehaviorRelay<[Story]>(value: [])
var offset = 0
let error = PublishSubject<String>()
let selectedFeedType: BehaviorRelay<FeedType> = BehaviorRelay(value: .best)
override init() {
super.init()
let refreshRequest = loading.asObservable().sample(refreshTrigger).flatMap { loading -> Observable<[Story]> in
if loading {
return Observable.empty()
} else {
self.offset = 0
return self.fetchStories(type: self.selectedFeedType.value, offset: self.offset)
}
}
let loadMoreRequest = loading.asObservable().sample(loadMoreTrigger).flatMap { loading -> Observable<[Story]> in
if loading {
return Observable.empty()
} else {
self.offset += 10
return self.fetchStories(type: self.selectedFeedType.value, offset: self.offset)
}
}
let request = Observable.merge(refreshRequest, loadMoreRequest).share(replay: 1)
let response = request.flatMap { (stories) -> Observable<[Story]> in
request.do(onError: { error in
self.error.onNext(error.localizedDescription)
}).catchError { (error) -> Observable<[Story]> in
Observable.empty()
}
}.share(replay: 1)
Observable.combineLatest(request, response, stories.asObservable()) { request, response, stories in
return self.offset == 0 ? response : stories + response
}.sample(response).bind(to: stories).disposed(by: disposeBag)
Observable.merge(request.map{_ in true}, response.map{_ in false}, error.map{_ in false}).bind(to: loading).disposed(by: disposeBag)
}
Then when i checking loading observer i have false -> true, instead of true -> false. I just don't understand why it happening.
loading.subscribe {
print($0)
}.disposed(by: disposeBag)
In my viewController i call refreshTrigger on viewWillAppear using rx.sentMessage
Here is getFeed function:
func getFeed(type: FeedType, offset: Int) -> Observable<[Story]> {
return provider.rx.request(.getFeed(type: type, offset: offset)).asObservable().flatMap { (response) -> Observable<[Story]> in
do {
let feedResponse = try self.jsonDecoder.decode(BaseAPIResponse<[Story]>.self, from: response.data)
guard let stories = feedResponse.data else { return .error(APIError.requestFailed)}
return .just(stories)
} catch {
return .error(error)
}
}.catchError { (error) -> Observable<[Story]> in
return .error(error)
}
}
Your request and response observables are emitting values at the exact same time. Which one shows up in your subscribe first is undefined.
Specifically, request doesn't emit a value until after the fetch request completes. Try this instead:
Observable.merge(
loadMoreTrigger.map { true },
refreshTrigger.map { true },
response.map { _ in false },
error.map { _ in false }
)
.bind(to: loading)
.disposed(by: disposeBag)
There are lots of other problems in your code but the above answers this specific question.
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)")
}
}
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)
}
}