Search This Blog

Sunday, August 26, 2018

Test iOS Project for tutu.ru. Part 1

Description

Full task description can be found here on GitHub


What need to be done

1. Application should have TabBar with two tabs:
  1. Schedule ("Расписание") 
  2. About ("О приложении")

2. Schedule Screen ("Расписание")
The screen should allow you to select:
  1. Departure station(Станция "отправления")
  2. Arrival station(Станция "прибытия")
  3. Date of departure(Дату отправления)

3. Station List Screen ("Экран выбора станции")
The station selection screen must be built on the basis of UITableView and it should:
Contain the general list of stations (see the attached file), grouped by the value of "Country, City". A complete list of groups and elements should be presented on one screen, with the ability to scroll through the entire content.
Provide the ability to search for part of the name (both initial and incoming, regardless of the register). Search must be performed on the same screen, where the list of stations is presented, using the UISearchController.

4. Station Detail Screen ("Детальной информация о конкретной станции")
Display detailed information about a particular station (naming and its full address, including city, region and country).

5. About Screen ("О приложении")
In this section you need to post information about:
  1. Author
  2. Application Version
Input data
{
  "citiesFrom" : [  ], //массив пунктов отправления
  "citiesTo" : [  ] //массив пунктов назначения
}
City object
{
 "countryTitle" : "Россия", //название страны
 "point" : { //координаты города
  "longitude" : 50.64357376098633,
  "latitude" : 55.37233352661133
 },
 "districtTitle" : "Чистопольский район", //название района
 "cityId" : 4454, //идентификатор города
 "cityTitle" : "Чистополь", //название города
 "regionTitle" : "Республика Татарстан", //название региона
 "stations" : [...] //массив станций
}
Station object
{
 "countryTitle" : "Россия", //название страны (денормализация данных, дубль из города)
 "point" : { //координаты станции (в общем случае отличаются от координат города)
  "longitude" : 50.64357376098633,
     "latitude" : 55.37233352661133
 },
 
 "districtTitle" : "Чистопольский район", //название района
 "cityId" : 4454, //идентификатор города
 "cityTitle" : "город Чистополь", //название города
 "regionTitle" : "Республика Татарстан", //название региона
 
 "stationId" : 9362, //идентификатор станции
 "stationTitle" : "Чистополь" //полное название станции
}



Parsing JSON 

First of all we need to extract data from JSON file and convert this JSON to objects. Parsing JSON can be long term task, so we must do such tasks in background using.
DispatchQueue.global(qos: .userInitiated).async
Also for more clarity that this task can be long we add pause for 3 seconds with
sleep(3)
Let's create service for parsing JSON. This service will be Singleton.
import Foundation

class JSONParsingService {
    
    // 1 
    typealias ScheduleCompletion = (Schedule) -> Void
    
    typealias JSONDictionary = [String: Any]
    
    // 2 
    static let instance = JSONParsingService()
    
    // 3 
    func getSchedule(completion: @escaping ScheduleCompletion) {
        // 4 
        DispatchQueue.global(qos: .userInitiated).async { [weak self] in // 5 
            // 6 
            guard let strongSelf = self else { return }
            sleep(3) // 7 
            // 8 
            if let jsonObject = strongSelf.parseJSONData(from: "allStations") {
                // 9 
                let schedule = Schedule(dictionary: jsonObject)
                completion(schedule)
            }
        }
    }
    
    // 10 
    private func parseJSONData(from file: String) -> JSONDictionary? {
        // 11 
        guard let jsonUrl = Bundle.main.url(
            forResource: file, withExtension: "json") else { return nil }
        // 12 
        do {
            let jsonData = try Data(contentsOf: jsonUrl)
            if let jsonObject = try JSONSerialization.jsonObject(with: jsonData,
                                                                 options: []) as? JSONDictionary {
                return jsonObject
            }
        } catch let error {
            print("Error happened when parsing json: \(error.localizedDescription)")
        }
        // 13 
        return nil
    }
}
There are following main things in JSONParsingService:
  1. Using typealias for more clear and short names
  2. Service supposed to be singleton, so instance creation as static property 
  3. Public API for Service, getting Schedule object in completion handler
  4. Using background task for long term tasks, such as extracting JSON data from file and convert it to objects 
  5. Avoiding retain cycle with weak self
  6. Checking self for nil and return in case of nil
  7. Simulate long term task - making 3 seconds pause
  8. Get JSON data from file named "allStations"
  9. Creating Schedule object from JSON data
  10. Get JSON dictionary from file and return Optional Value because file could not exist or has wrong data
  11. Checking that path to file is correct
  12. Trying to create Data from content of file
  13. Return nil by default

Creating the Model

Schedule

struct Schedule {
    
    typealias JSONDictionary = Dictionary<String, Any>
    
    typealias JSONArray = Array<JSONDictionary>
    
    // 1 
    private(set) var citiesFrom = [City]()
    
    private(set) var citiesTo = [City]()
    
    // 2 
    init(dictionary: JSONDictionary = JSONDictionary()) {
        if let citiesFromDict = dictionary["citiesFrom"] as? JSONArray {
            for cityFromDict in citiesFromDict {
                let city = City(dictionary: cityFromDict)
                citiesFrom += [city]
            }
        }
        if let citiesToDict = dictionary["citiesTo"] as? JSONArray {
            for cityToDict in citiesToDict {
                let city = City(dictionary: cityToDict)
                citiesTo += [city]
            }
        }
    }
}
  1. Init properties with default values - empty arrays
  2. Init object from JSON dictionary

City

import Foundation

extension String {
    static var unknownValue: String {
        // return unknown 
        return "неизвестно"
    }
}

extension Int {
    static var unknownValue: Int {
        return -1
    }
}


struct City {
    
    typealias JSONDictionary = Dictionary<String, Any>
    
    typealias JSONArray = Array<JSONDictionary>
    
    private(set) var countryTitle: String

    private(set) var districtTitle: String
    
    private(set) var cityId: Int
    
    private(set) var cityTitle: String
    
    private(set) var regionTitle: String
    
    private(set) var point: PointCoordinates
    
    private(set) var stations = [Station]()
    
    init(dictionary: JSONDictionary) {
        // 1 
        if let countryTitle = dictionary["countryTitle"] as? String {
            self.countryTitle = countryTitle
        // 2 
        } else {
            countryTitle = String.unknownValue
        }
        if let districtTitle = dictionary["districtTitle"] as? String {
            self.districtTitle = districtTitle
        } else {
            self.districtTitle = String.unknownValue
        }
        if let cityId = dictionary["cityId"] as? Int {
            self.cityId = cityId
        } else {
            self.cityId = Int.unknownValue
        }
        if let cityTitle = dictionary["cityTitle"] as? String {
            self.cityTitle = cityTitle
        } else {
            self.cityTitle = String.unknownValue
        }
        if let regionTitle = dictionary["regionTitle"] as? String {
            self.regionTitle = regionTitle
        } else {
            self.regionTitle = String.unknownValue
        }
        
        if let pointDict = dictionary["point"] as? JSONDictionary {
            self.point = PointCoordinates(dictionary: pointDict)
        } else {
            self.point = PointCoordinates()
        }
        
        if let stationsArray = dictionary["stations"] as? JSONArray {
            for stationItemDict in stationsArray {
                let station = Station(dictionary: stationItemDict)
                stations += [station]
            }
        }
    }
}


// 3 
extension City: CustomStringConvertible {
    var description: String {
        return "City(id: \(cityId), title: \(cityTitle), region: \(regionTitle), "
            + "country: \(countryTitle), stations count: \(stations.count), "
            + "point: (\(point.longitude, point.latitude)))"
    }
}
  1. Trying to get value from JSON dictionary
  2. If dictionary has no value then set default value to property
  3. Make City type conform to CustomStringConvertible protocol for handy printing City object to console


Station

struct Station {
    
    typealias JSONDictionary = Dictionary<String, Any>
    
    private(set) var countryTitle: String
    
    private(set) var districtTitle: String
    
    private(set) var cityId: Int
    
    private(set) var cityTitle: String
    
    private(set) var regionTitle: String
    
    private(set) var stationId: String
    
    private(set) var stationTitle: String
    
    private(set) var point: PointCoordinates
    
    init(dictionary: JSONDictionary) {
        if let countryTitle = dictionary["countryTitle"] as? String {
            self.countryTitle = countryTitle
        } else {
            self.countryTitle = String.unknownValue
        }
        if let districtTitle = dictionary["districtTitle"] as? String {
            self.districtTitle = districtTitle
        } else {
            self.districtTitle = String.unknownValue
        }
        if let cityId = dictionary["cityId"] as? Int {
            self.cityId = cityId
        } else {
            self.cityId = Int.unknownValue
        }
        if let cityTitle = dictionary["cityTitle"] as? String {
            self.cityTitle = cityTitle
        } else {
            self.cityTitle = String.unknownValue
        }
        if let regionTitle = dictionary["regionTitle"] as? String {
            self.regionTitle = regionTitle
        } else {
            self.regionTitle = String.unknownValue
        }
        if let stationId = dictionary["stationId"] as? String {
            self.stationId = stationId
        } else {
            self.stationId = String.unknownValue
        }
        if let stationTitle = dictionary["stationTitle"] as? String {
            self.stationTitle = stationTitle
        } else {
            self.stationTitle = String.unknownValue
        }
        if let pointDict = dictionary["point"] as? JSONDictionary {
            self.point = PointCoordinates(dictionary: pointDict)
        } else {
            self.point = PointCoordinates()
        }
    }
}

extension Station: CustomStringConvertible {
    
    var description: String {
        return "Station: \(stationTitle), with id: \(stationId), country: \(countryTitle), city: \(cityTitle), "
            + "region: \(regionTitle), district: \(districtTitle), "
            + "point: \(point.longitude, point.latitude)"
    }
}


PointCoordinates

struct PointCoordinates {
    
    typealias JSONDictionary = Dictionary<String, Any>
    
    private(set) var longitude: Double
    
    private(set) var latitude: Double
    
    // 1 
    init(longitude: Double = 0.0, latitude: Double = 0.0) {
        self.longitude = longitude
        self.latitude = latitude
    }
    
    init(dictionary: JSONDictionary) {
        var longitude = 0.0
        var latitude = 0.0
        if let longitudeVal = dictionary["longitude"] as? Double {
            longitude = longitudeVal
        }
        if let latitudeVal = dictionary["latitude"] as? Double {
            latitude = latitudeVal
        }
        self.init(longitude: longitude, latitude: latitude)
    }
    
}
  1. Init with default values

Creating User Interface

Let's describe UI that we want to implement.

Navigation Controller is the root view controller. The main View Controller is CityListViewController. It has Segmented Control with two options: "From" and "To" for selecting type of cities to display and Table View for displaying list of cities. After data will be loaded, in Segmented Control will be displayed count of cities of each type. Table View is used for displaying list of cities. In viewDidLoad method we start getting Schedule object via JSONParsingService. Initially Table View is hidden. When loading is started Activity Indicator become visible and animated. When data is fully loaded Table View will become visible and Activity Indicator become hidden. When we switch between "From" an "To" items in Segmented Control, according list of cities will be displayed. Also, because we use one Table View for two different lists of cities we need to save scroll position for each list.

View Controllers in Stroyboard

Source Code of CityListViewController
import UIKit

class CityListViewController: UIViewController {
    
    // MARK: - Inner Types
    // 1
    enum CityType {
        case from
        case to
    }
    
    // MARK: - Properties
    // 2
    private var isLoading = false
    
    private let cellId = "StationCell"
    
    // 3
    private var cityList = [City]()
    
    // 4
    private var currentCitiesFromIndexPath = IndexPath(item: 0, section: 0)
    
    private var currentCitiesToIndexPath = IndexPath(item: 0, section: 0)
    
    // 5 
    private var schedule = Schedule() {
        didSet {
            reloadSegmentedControl(schedule: schedule)
            reloadTableViewData(schedule: schedule)
        }
    }
    
    // 6 
    private var selectedCityType: CityType = .from {
        didSet {
            reloadTableViewData(schedule: schedule)
        }
    }
    
    // MARK: - IBOutlets
    @IBOutlet weak var tableView: UITableView! {
        didSet {
            tableView.dataSource = self
            tableView.delegate = self
            tableView.alpha = 0.0
        }
    }
    
    @IBOutlet weak var activityIndicator: UIActivityIndicatorView!
    
    @IBOutlet weak var segmentedControl: UISegmentedControl!
    
    // MARK: - IBActions
    // 7 
    @IBAction func segmentedControlChanged(_ sender: UISegmentedControl) {
        if sender.selectedSegmentIndex == 0 {
            selectedCityType = .from
        } else if sender.selectedSegmentIndex == 1 {
            selectedCityType = .to
        }
    }
    
    // MARK: - View Life Cycle
    // 8 
    override func viewDidLoad() {
        super.viewDidLoad()
        getSchedule()
    }
    
    // MARK: - Main Logic
    // 9
    private func reloadSegmentedControl(schedule: Schedule) {
        guard !isLoading else { return }
        DispatchQueue.main.async {
            let citiesFromCount = schedule.citiesFrom.count
            if let fromTitle = self.segmentedControl.titleForSegment(at: 0),
                citiesFromCount > 0 {
                self.segmentedControl.setTitle("\(fromTitle) \(citiesFromCount)", forSegmentAt: 0)
            }
            let citiesToCount = schedule.citiesTo.count
            if let toTitle = self.segmentedControl.titleForSegment(at: 1),
                citiesToCount > 0 {
                self.segmentedControl.setTitle("\(toTitle) \(citiesToCount)", forSegmentAt: 1)
            }
        }
    }
    
    // 10
    private func reloadTableViewData(schedule: Schedule) {
        guard !isLoading else { return }
        // 11
        DispatchQueue.main.async {
            let isFrom = self.selectedCityType == .from
            self.cityList = isFrom ? schedule.citiesFrom : schedule.citiesTo
            self.tableView.reloadData()
            // 12
            self.tableView.scrollToRow(at: isFrom ?
                self.currentCitiesFromIndexPath :
                self.currentCitiesToIndexPath, at: .top, animated: true)
        }
    }
    
    // 13
    private func getSchedule() {
        isLoading = true
        activityIndicator.startAnimating()
        JSONParsingService.instance.getSchedule { [unowned self] schedule in
            self.isLoading = false
            self.schedule = schedule
            // 14
            DispatchQueue.main.async {
                UIView.animate(withDuration: 0.6, animations: {
                    self.tableView.alpha = 1.0
                })
                self.activityIndicator.stopAnimating()
            }
        }
    }
    
}

// MARK: - UITableViewDataSource
extension CityListViewController: UITableViewDataSource {
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return cityList.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: cellId, for: indexPath)
        let city = cityList[indexPath.row]
        cell.textLabel?.text = "\(indexPath.row + 1) \(city.cityTitle)"
        cell.detailTextLabel?.text = city.countryTitle
        return cell
    }
    
}

// MARK: - UITableViewDelegate
extension CityListViewController: UITableViewDelegate {
    
    // 15
    func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
        if let indexPath = tableView.indexPathsForVisibleRows?.first {
            if selectedCityType == .from {
                currentCitiesFromIndexPath = indexPath
            } else {
                currentCitiesToIndexPath = indexPath
            }
        }
    }
}
Let's go through the code and explore what happens in it:
  1. Enum for switching between "cities from" and "cities to"
  2. Flag to identify that loading (JSON parsing) in progress
  3. Array of cities to display in table
  4. Index Path to remember for auto scrolling to position
  5. Schedule property init with default init
  6. When switching between tabs in segmented control
  7. When switching between tabs in segmented control update property
  8. Start loading schedule with Service when view did load
  9. Update titles in segmented control when data is loaded. Show count of cities of each type: from and to
  10. Reload table view with new data
  11. Update UI on main queue
  12. Scrolling to last remembered index path
  13. Start loading Schedule via JSONParsingService
  14. Update UI on main queue when schedule is loaded
  15. Remember last showing position in table view for selected city type

Results

This is how it looks on device

Source Code

Source code for this part of tutorial can be found here on GitHub

4 comments:

  1. Your article is one of its kind which explained every bit of Custom Build Website. looking for further valuable articles from you

    ReplyDelete
  2. Really amazing information you have shared i was looking for this kind of unique information please do share that kind of unique information more as you can.
    -Web Development Services

    ReplyDelete
  3. Really great information you've offered; I've been seeking for stuff like this, so please continue to share it as much as you can. Best Custom Websites

    ReplyDelete
  4. Thank you very much for sharing this informational blog.I could not find such kind of information in any other site.I'll come back for more fantastic material.
    Best wishes, and good luck.
    Custom Website

    ReplyDelete