Category: iOS

  • MVVM+C: Making Navigation Testable and Scalable

    AI helps us develop software faster, but faster changes also increase the risk of regressions. That makes strong automated test coverage even more important.

    Navigation is one area where plain MVVM often becomes difficult to test.

    The challenge with navigation in MVVM

    In a traditional MVVM setup, the ViewController usually handles both UI and navigation: pushing the next screen, composing modules, handling deep links, and managing flow decisions.

    Testing this through unit tests is difficult because the logic is tightly coupled to UIKit. As a result, navigation is often covered by UI tests.

    UI tests work, but they can be slow, flaky, and expensive to maintain, which can significantly hurt the developer experience.

    Moving navigation into a Coordinator

    MVVM+C addresses this by moving navigation into a Coordinator.

    The ViewController focuses on UI, while the Coordinator handles navigation, module composition, deep links, child flows, and flow completion.

    MVVM + Coordinator architecture: AppCoordinator manages feature coordinators, with each module containing a ViewController and ViewModel.

    With navigation dependencies abstracted behind testable interfaces, this makes navigation decisions easier to cover with fast, reliable unit tests. UI tests can then focus on a small number of critical end-to-end scenarios.

    Scaling across features and teams

    Another benefit is loose coupling between modules. Each feature can own its own MVVM+C flow, which makes the architecture easier to scale across teams.

    MVVM+C is not just about cleaner navigation. It makes navigation isolated, scalable, and testable.

  • How to add the SwiftLint without pain

    Why do you need SwiftLint?

    1. It speed ups code reviews, you stop wasting time on code formatting, force casting, and other common issues
    2. It improves the code quality, code becomes more readable, and maintainable

    Integration

    1. Add SwiftLint to a podfile

    Or use another option from the official documentation.

      # A tool to enforce Swift style and conventions.
      pod 'SwiftLint', '0.43.1'

    2. Add this .swiftlint.yml config file to the project root folder

    The main trick to make integration easy is to disable all rules at first.

    disabled_rules:
    - trailing_whitespace
    - force_cast
    - force_unwrapping
    - force_try
    - empty_string
    - line_length
    - mark
    - weak_delegate
    - unneeded_break_in_switch
    - identifier_name
    - function_parameter_count
    - unused_closure_parameter
    - vertical_whitespace
    - opening_brace
    - control_statement
    - statement_position
    - comma
    - colon
    - trailing_comma
    
    opt_in_rules:
    - empty_count
    - empty_string
    
    excluded:
    - Carthage
    - Pods
    - SwiftLint/Common/3rdPartyLib
    
    line_length:
        warning: 150
        error: 200
        ignores_function_declarations: true
        ignores_comments: true
        ignores_urls: true
    
    function_body_length:
        warning: 300
        error: 500
    
    function_parameter_count:
        warning: 6
        error: 8
    
    type_body_length:
        warning: 300
        error: 500
    
    file_length:
        warning: 1000
        error: 1500
        ignore_comment_only_lines: true
    
    cyclomatic_complexity:
        warning: 15
        error: 25
    
    reporter: "xcode"

    3. Build a project, add additional rules to the disabled_rules section, if there are many warnings for them

    4. Delete rules from the disabled_rules section one by one and fix all the warnings

    5. Leave the trailing_whitespace and other rules that you don’t want to fix in the disabled_rules section

    In the end, you will have something like this in your config file.

    disabled_rules:
    - trailing_whitespace
    
    opt_in_rules:
    - empty_count
    - empty_string
    
    excluded:
    - Carthage
    - Pods
    - SwiftLint/Common/3rdPartyLib
    
    line_length:
        warning: 150
        error: 200
        ignores_function_declarations: true
        ignores_comments: true
        ignores_urls: true
    
    function_body_length:
        warning: 300
        error: 500
    
    function_parameter_count:
        warning: 6
        error: 8
    
    type_body_length:
        warning: 300
        error: 500
    
    file_length:
        warning: 1000
        error: 1500
        ignore_comment_only_lines: true
    
    cyclomatic_complexity:
        warning: 15
        error: 25
    
    reporter: "xcode"

    Some ideas on how to fix common code issues

    Force Cast Violation: Force casts should be avoided. (force_cast)

    Problem:

    let cell = collectionView.dequeueReusableCell(
        withReuseIdentifier: "DocumentCollectionViewCell",
        for: indexPath
    ) as! DocumentCollectionViewCell
    

    Solution:

    guard let cell = collectionView.dequeueReusableCell(
        withReuseIdentifier: "DocumentCollectionViewCell",
        for: indexPath
    ) as? DocumentCollectionViewCell else { fatalError() }
    

    Force Try Violation: Force tries should be avoided. (force_try)

    Problem:

    let workingDirectory = try! FileManager.default.url(
        for: .documentDirectory,
        in: .userDomainMask,
        appropriateFor: nil,
        create: false
    )
    

    Solution:

    do {
        let workingDirectory = try FileManager.default.url(
            for: .documentDirectory,
            in: .userDomainMask,
            appropriateFor: nil,
            create: false
        )
    } catch {
        print(error)
    }
    

    Exceptions

    In very rare cases you will not be able to fix the warning. For example, when you use a framework and you cannot easily change its API. Then you can switch off a SwiftLint rule for a particular code block using // swiftlint:disable rule_name and // swiftlint:enable rule_name comments.

  • Мой конспект Apple Event June 2021


    Apple показали iOS 15, Mac OS Monterey и многое другое. Это мой конспект, который я использую, чтобы не забыть важные моменты для себя. Записи сделал с точки зрения возможностей практического применения.

    Что нового для пользователей

    Live Text

    • Можно будет фотографировать названия, состав товаров и переводить в текстовый вид, чтобы находить инструкции, отзывы в интернете, страницы товаров у производителей.
    • Можно фотографировать объявления на стенах и переводить в цифровой текстовый вид,
    • чтобы потом копировать адрес, телефон.

    Maps

    • Immersive walking instructions можно будет использовать в поездках в места, где это хорошо работает.

    Universal Controls

    • Можно будет использовать iPad как второй переносной экран к Macbook

    Safari Tab groups

    • Это лучше, чем закладки в папках, так как закладки остаются навсегда, но при этом не хочется захламлять свои закладки временными вкладками, которые хочется продолжить изучать только завтра.

    Spatial Audio

    • AirPods Pro дают не только шумоподавление, но и пространственный звук, который стал еще лучше

    iOS Focus

    • По моему личному опыту фокус очень важен для продуктивности. Можно будет группировать уведомления с приложений. Я выключал все уведомления со всех мессенджеров, кроме рабочего и со всех соц. сетей. Из-за этого я иногда пропускал важные сообщения. Теперь верну их, но создам для них отдельную группу, чтобы читать их только когда не работаю.

    Shortcuts app on Mac

    • Любая автоматизация увеличивает производительность. Я уже использую автоматизацию в Trello для обновления чек-листов и задач на день. Можно будет использовать для автоматизации открытия наборов окон перед работой над разными проектами. Это позволит быстрее приступать к работе.

    Privacy

    • Борьба со спамом и трекингом, анонимность в интернете – это хорошо и важно. Privacy в Apple Mail позволит запретить трекинг открытия письма через скрытый пиксель. Появится защита IP адреса в Mail.
    • С помощью Hide My Email в составе iCloud + можно будет создавать о прокси email адреса для всяких мелких сервисов и магазинов, чтобы скрыть свой настоящий адрес

    SpotLight, Health, Photos, Watch OS

    • Много мелких улучшений, хорошо в целом

    SharePlay, Facetime, Message

    • API SharePlay станет доступно для приложений
    • Не использовал и не собираюсь – не хватает кроссплатформенности и ее не добавили.

    Что нового для разработчиков

    APIs

    • SharePlay
    • Voice isolation, wide spectrum audio, portrait mode for video conferencing apps
    • New APIs for Focus and Notifications, for screen time
    • RealityKit, Object Capture для мерчендайза с AR

    Swift

    • Concurrency (async, await, actor)

    AppStore

    • Multiple product pages
    • In-App events

    XCode Cloud

    • build in cloud
    • test in cloud in parallel
    • distribute to testers

    TestFlight for Mac

    Ссылки

    Apple Event 2021, WWDC

    https://www.apple.com/apple-events/june-2021/

    iOS 15

    https://www.apple.com/ios/ios-15-preview/

    Mac OS X Monterey

    https://www.apple.com/macos/monterey-preview/

  • iOS – How to remove “Back” text from the back button

    The best solution is to create base classes for UIViewController and UITableViewController and override their init methods. This will allow you to add ViewControllers to the NavigationController programmatically after a rootViewController. If you put this code in the ViewDidLoad method, it will not be called in this case and the next controller will be pushed with a back text.

    BaseViewController in Objective C:

    #import "BaseViewController.h"
    
    @interface BaseViewController ()
    
    @end
    
    @implementation BaseViewController
    
    - (instancetype)initWithNibName:(NSString *)nibNameOrNil bundle:(NSBundle *)nibBundleOrNil
    {
     self = [super initWithNibName:nibNameOrNil bundle:nibBundleOrNil];
     if (self) {
     self.navigationItem.backBarButtonItem = [[UIBarButtonItem alloc] initWithTitle:@"" style:UIBarButtonItemStylePlain target:nil action:nil];
     }
     return self;
    }
    
    @end
    

    BaseTableViewController:

    #import "BaseTableViewController.h"
    
    @interface BaseTableViewController ()
    
    @end
    
    @implementation BaseTableViewController
    
    - (instancetype)initWithStyle:(UITableViewStyle)style
    {
     self = [super initWithStyle:style];
     if (self) {
     self.navigationItem.backBarButtonItem = [[UIBarButtonItem alloc] initWithTitle:@"" style:UIBarButtonItemStylePlain target:nil action:nil];
     }
     return self;
    }
    
    @end
    
  • Using SFSafariViewController to open URL inside app without transitioning to Safari or using WebView

    RxSwift/RxCocoa example:

    import SafariServices
    
    learnMoreButton.rx.tap
        .subscribe(onNext: {
            guard let url = URL(string: "https://google.com") else { return }
            let svc = SFSafariViewController(url: url)
            self.present(svc, animated: true, completion: nil)
        })
        .disposed(by: disposeBag)
    
  • Chaining example on RxSwift

    This is one of the reasons I love RxSwift. It allows to chain requests beautifully.

    Here I authorize user by getting auth token, then I request userInfo, then I request profile.

    func loadUserInfoAndProfile(login: String, password: String) {
        UserManager.shared.auth(login: login, password: password)
            .flatMap { _ in
                return UserManager.shared.info()
            }
            .map { (result: APIResult) -> UserInfo? in
                switch result {
                case .success(let value):
                    UserManager.shared.userInfo = value
                    return value
                case .failure(let error):
                    print(error)
                    return nil
                }
            }
            .flatMap { userInfo in
                return ServiceManager.shared.profileView()
            }
            .map { (result: APIResult) -> ServiceProfile? in
                switch result {
                case .success(let value):
                    ServiceManager.shared.profile.value = value
                    return value
                case .failure(let error):
                    print(error)
                    return nil
                }
            }
            .subscribe(onNext: { _ in
                self.showTabs()
            }, onError: { error in
                self.showTutorialViewController()
            })
            .disposed(by: disposeBag)
    }
    
  • Что нового в iOS 12

    ARKit 2
    Multiuser and Persistent AR. – сохранение данных из AR сессии в приложении, многопользовательский режим AR.
    Object Detection. – распознавание объектов реального мира и добавление их в AR приложения.

    Siri Shortcuts
    Siri предсказывает быстрый доступ (shortcuts) для действий в приложении.

    Health Records
    Доступ к информации о здоровье в приложениях.

    Effects
    Показ стикеров и сообщений в iMessage в виде эффектов.

    Interactive Controls in Notifications
    Полная кастомизация уведомлений.

    Authentication Services
    Позволяет шарить логин-сессию между Safari и приложением, чтобы логинить пользователя проще.

    CarPlay for Navigation Apps

    Network Framework
    Доступ к протоколам TLS, TCP, UDP.
    URLSession, работающий с HTTP, построен поверх этого фреймворка.

    Natural Language
    Получение метаданных из текста.

    Deprecation of OpenGL ES
    OpenGL ES deprecated, необходимо использовать только Metal.

  • Получение строки, содержащей склонения дней дня день

    Достаточно часто используемая штука, поэтому решил сохранить сниппет:

    func dayStringFromNumberOfDays(_ numberOfDays: Int) -> String {
        let lastDigit = numberOfDays % 10
        if (numberOfDays >= 11 && numberOfDays <= 19)
        {
            return "дней";
        }
        else
        {
            if lastDigit == 1 { return "день" };
            if lastDigit > 1 && lastDigit < 5 { return "дня" };
            if lastDigit == 0 || lastDigit >= 5 { return "дней" };
        }
        return ""
    }
    

    Использование:

    let daysLeft = 11
    let dayString = self.dayStringFromNumberOfDays(daysLeft)
    let fullString = "\(daysLeft) \(dayString)"
    print(fullString)
    

    Результат: 11 дней

  • Swift build failed. No errors.

    This happened to me while I was writing in Swift 4.1 in XCode 9.3. I use RxSwift also. It never happened while I was writing in Objective C. There were no any logs in Report Navigator too. Project just compiled without errors, then failed. Couldn’t figure out why.

    In my case what I did and it helped:

    1. Commit changes in Git and then discard changes in XCode if it shows “M” near files
    2. Clear derived data: Xcode -> Preferences -> location
    3. Clean build folder: Shift + Cmd + Alt + K
    4. Restart XCode

    After this project tried to compile and showed errors. The error was here:

        do {
            let jsonData = try JSONSerialization.data(withJSONObject: timingsDict, options: .prettyPrinted)
            let decoder = JSONDecoder()
            let pTiming = try decoder.decode(PTiming.self, from: jsonData)
            observer.onNext(timing)
            observer.onCompleted()
        } catch {
            print(error.localizedDescription)
            observer.onError(error)
        }
    

    I passed wrong variable name in observer.onNext(timing). It should have been pTiming.

  • Using RACCommand and creating custom RACSignals in Objective C

    To bind some action to the button click one can use just a custom RACSignal. But then you have to add three more things in every case:
    1. Disable button, while action is performing;
    2. Show, that action is in progress;
    3. Disable button, if input is incorrect and button should not be able to be tapped.

    Instead, if you use RACCommand, you get all these things in a box.

    Let’s say, we want to bind RACCommand verifyPhoneCommand to a button. We can create a command in a lazy property in a ViewModel:

    - (RACCommand *)verifyPhoneCommand
    {
        if (! _verifyPhoneCommand) {
            _verifyPhoneCommand = [[RACCommand alloc] initWithEnabled:self.phoneValidSignal
                                                          signalBlock:^RACSignal * _Nonnull(id  _Nullable input) {
                                                              return [self verifyPhone];
                                                          }];
        }
        return _verifyPhoneCommand;
    }
    

    Here phoneValidSignal is this signal, that is formed by observing a phone variable:

    - (RACSignal *)phoneValidSignal
    {
        if (!_phoneValidSignal) {
            _phoneValidSignal = [RACObserve(self, phone) map:^id(NSString *phone) {
                return @(phone.length > 0);
            }];
        }
        return _phoneValidSignal;
    }
    

    We pass a RACSignal verifyPhone to the RACCommand to do a job:

    - (RACSignal *)verifyPhone
    {
        return [RACSignal createSignal:^RACDisposable * _Nullable(id  _Nonnull subscriber) {
    
            [[PhoneManager sharedManager]
             requestVerifyPhone:self.phone
             success:^{
    
                 [subscriber sendNext:@(YES)];
                 [subscriber sendCompleted];
                 
             } failure:^(NSError *error) {
                 
                 [subscriber sendError:error];
                 
             }];
            
            return nil;
        }];
    }
    

    This is how we bind a RACCommand to a button:

    self.smsRequestButton.rac_command = self.viewModel.verifyPhoneCommand;
    

    To track execution of a RACCommand and show an activity indicator we can use executing property:

    [self.viewModel.verifyPhoneCommand.executing subscribeNext:^(NSNumber*  _Nullable x) {
            if (x.boolValue) {
                [self.spinnerView startAnimating];
            }
            else {
                [self.spinnerView stopAnimating];
            }
        }];
    

    If errors happen during RACCommand execution we can process those errors:

    [self.viewModel.verifyPhoneCommand.errors subscribeNext:^(NSError * _Nullable error) {
            [ErrorUtil showError:error from:self onRepeat:^{
                
            }];
        }];