From 1c735a92286a214e9932878e36f9f2142e9b802c Mon Sep 17 00:00:00 2001 From: John Estropia Date: Sat, 29 Aug 2020 20:02:05 +0900 Subject: [PATCH] improve Pokedex demo --- Demo/Demo.xcodeproj/project.pbxproj | 32 +- .../Modern.PokedexDemo.Ability.swift | 33 -- .../Modern.PokedexDemo.ItemCell.swift | 386 ++++++++++++++++++ .../Modern.PokedexDemo.ItemView.swift | 141 ------- .../Modern.PokedexDemo.ListView.swift | 72 ++++ ...odern.PokedexDemo.ListViewController.swift | 115 ++++++ .../Modern.PokedexDemo.MainView.swift | 46 +-- .../PokedexDemo/Modern.PokedexDemo.Move.swift | 48 --- .../Modern.PokedexDemo.PokedexEntry.swift | 16 +- .../Modern.PokedexDemo.PokemonDetails.swift | 27 ++ .../Modern.PokedexDemo.PokemonDisplay.swift | 28 +- .../Modern.PokedexDemo.PokemonForm.swift | 41 +- .../Modern.PokedexDemo.Service.swift | 98 +++-- .../PokedexDemo/Modern.PokedexDemo.swift | 5 +- Demo/Sources/Helpers/ImageDownloader.swift | 72 ++++ Demo/Sources/Helpers/NetworkImageView.swift | 63 +-- Sources/ListPublisher.swift | 11 +- Sources/ObjectPublisher.swift | 12 +- 18 files changed, 820 insertions(+), 426 deletions(-) delete mode 100644 Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.Ability.swift create mode 100644 Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.ItemCell.swift delete mode 100644 Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.ItemView.swift create mode 100644 Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.ListView.swift create mode 100644 Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.ListViewController.swift delete mode 100644 Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.Move.swift create mode 100644 Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.PokemonDetails.swift create mode 100644 Demo/Sources/Helpers/ImageDownloader.swift diff --git a/Demo/Demo.xcodeproj/project.pbxproj b/Demo/Demo.xcodeproj/project.pbxproj index a2dbb99..f803d35 100644 --- a/Demo/Demo.xcodeproj/project.pbxproj +++ b/Demo/Demo.xcodeproj/project.pbxproj @@ -11,8 +11,11 @@ B531EFE924EB5A53005F247D /* Modern.PokedexDemo.PokedexEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = B531EFE824EB5A52005F247D /* Modern.PokedexDemo.PokedexEntry.swift */; }; B531EFEB24EB5ECD005F247D /* Modern.PokedexDemo.Service.swift in Sources */ = {isa = PBXBuildFile; fileRef = B531EFEA24EB5ECD005F247D /* Modern.PokedexDemo.Service.swift */; }; B531EFED24EB7453005F247D /* Modern.PokedexDemo.MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B531EFEC24EB7453005F247D /* Modern.PokedexDemo.MainView.swift */; }; - B54269C624ED190C00A66C23 /* Modern.PokedexDemo.ItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B54269C524ED190C00A66C23 /* Modern.PokedexDemo.ItemView.swift */; }; B566C8E624ED6B98001134A1 /* NetworkImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B566C8E524ED6B98001134A1 /* NetworkImageView.swift */; }; + B566C8E824F9D406001134A1 /* Modern.PokedexDemo.ListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B566C8E724F9D406001134A1 /* Modern.PokedexDemo.ListView.swift */; }; + B566C8EA24F9D412001134A1 /* Modern.PokedexDemo.ListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B566C8E924F9D412001134A1 /* Modern.PokedexDemo.ListViewController.swift */; }; + B566C8EC24F9D694001134A1 /* Modern.PokedexDemo.ItemCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B566C8EB24F9D694001134A1 /* Modern.PokedexDemo.ItemCell.swift */; }; + B566C8EE24FA1EA3001134A1 /* Modern.PokedexDemo.PokemonDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = B566C8ED24FA1EA3001134A1 /* Modern.PokedexDemo.PokemonDetails.swift */; }; B5A3911D24E5429200E7E8BD /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5A3911C24E5429200E7E8BD /* AppDelegate.swift */; }; B5A3911F24E5429200E7E8BD /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5A3911E24E5429200E7E8BD /* SceneDelegate.swift */; }; B5A3912124E5429200E7E8BD /* Menu.MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5A3912024E5429200E7E8BD /* Menu.MainView.swift */; }; @@ -63,10 +66,9 @@ B5A391AE24E9150F00E7E8BD /* Modern.ColorsDemo.UIKit.DetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5A391AD24E9150F00E7E8BD /* Modern.ColorsDemo.UIKit.DetailViewController.swift */; }; B5A391B124E96AF600E7E8BD /* Modern.PokedexDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5A391B024E96AF600E7E8BD /* Modern.PokedexDemo.swift */; }; B5A391B424E96C0A00E7E8BD /* Modern.PokedexDemo.PokemonDisplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5A391B324E96C0A00E7E8BD /* Modern.PokedexDemo.PokemonDisplay.swift */; }; - B5A391B624E96C5500E7E8BD /* Modern.PokedexDemo.Move.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5A391B524E96C5500E7E8BD /* Modern.PokedexDemo.Move.swift */; }; B5A391B924E96F8500E7E8BD /* Modern.PokedexDemo.PokemonForm.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5A391B824E96F8500E7E8BD /* Modern.PokedexDemo.PokemonForm.swift */; }; B5A391BB24E970A400E7E8BD /* Modern.PokedexDemo.PokemonType.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5A391BA24E970A400E7E8BD /* Modern.PokedexDemo.PokemonType.swift */; }; - B5A391BD24E977E500E7E8BD /* Modern.PokedexDemo.Ability.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5A391BC24E977E500E7E8BD /* Modern.PokedexDemo.Ability.swift */; }; + B5E32C9024FA41F9003F46AD /* ImageDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5E32C8F24FA41F9003F46AD /* ImageDownloader.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -91,8 +93,11 @@ B531EFE824EB5A52005F247D /* Modern.PokedexDemo.PokedexEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Modern.PokedexDemo.PokedexEntry.swift; sourceTree = ""; }; B531EFEA24EB5ECD005F247D /* Modern.PokedexDemo.Service.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Modern.PokedexDemo.Service.swift; sourceTree = ""; }; B531EFEC24EB7453005F247D /* Modern.PokedexDemo.MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Modern.PokedexDemo.MainView.swift; sourceTree = ""; }; - B54269C524ED190C00A66C23 /* Modern.PokedexDemo.ItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Modern.PokedexDemo.ItemView.swift; sourceTree = ""; }; B566C8E524ED6B98001134A1 /* NetworkImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkImageView.swift; sourceTree = ""; }; + B566C8E724F9D406001134A1 /* Modern.PokedexDemo.ListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Modern.PokedexDemo.ListView.swift; sourceTree = ""; }; + B566C8E924F9D412001134A1 /* Modern.PokedexDemo.ListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Modern.PokedexDemo.ListViewController.swift; sourceTree = ""; }; + B566C8EB24F9D694001134A1 /* Modern.PokedexDemo.ItemCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Modern.PokedexDemo.ItemCell.swift; sourceTree = ""; }; + B566C8ED24FA1EA3001134A1 /* Modern.PokedexDemo.PokemonDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Modern.PokedexDemo.PokemonDetails.swift; sourceTree = ""; }; B5A3911924E5429200E7E8BD /* Demo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Demo.app; sourceTree = BUILT_PRODUCTS_DIR; }; B5A3911C24E5429200E7E8BD /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; B5A3911E24E5429200E7E8BD /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; @@ -142,10 +147,9 @@ B5A391AD24E9150F00E7E8BD /* Modern.ColorsDemo.UIKit.DetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Modern.ColorsDemo.UIKit.DetailViewController.swift; sourceTree = ""; }; B5A391B024E96AF600E7E8BD /* Modern.PokedexDemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Modern.PokedexDemo.swift; sourceTree = ""; }; B5A391B324E96C0A00E7E8BD /* Modern.PokedexDemo.PokemonDisplay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Modern.PokedexDemo.PokemonDisplay.swift; sourceTree = ""; }; - B5A391B524E96C5500E7E8BD /* Modern.PokedexDemo.Move.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Modern.PokedexDemo.Move.swift; sourceTree = ""; }; B5A391B824E96F8500E7E8BD /* Modern.PokedexDemo.PokemonForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Modern.PokedexDemo.PokemonForm.swift; sourceTree = ""; }; B5A391BA24E970A400E7E8BD /* Modern.PokedexDemo.PokemonType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Modern.PokedexDemo.PokemonType.swift; sourceTree = ""; }; - B5A391BC24E977E500E7E8BD /* Modern.PokedexDemo.Ability.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Modern.PokedexDemo.Ability.swift; sourceTree = ""; }; + B5E32C8F24FA41F9003F46AD /* ImageDownloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDownloader.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -303,6 +307,7 @@ B5A3917B24E6A76C00E7E8BD /* LazyView.swift */, B5A3917F24E787D900E7E8BD /* InstructionsView.swift */, B566C8E524ED6B98001134A1 /* NetworkImageView.swift */, + B5E32C8F24FA41F9003F46AD /* ImageDownloader.swift */, B5A391A724E90F1000E7E8BD /* UIImage+Extensions.swift */, B5A3915424E6857F00E7E8BD /* Menu */, ); @@ -380,7 +385,9 @@ B5A391B024E96AF600E7E8BD /* Modern.PokedexDemo.swift */, B531EFEA24EB5ECD005F247D /* Modern.PokedexDemo.Service.swift */, B531EFEC24EB7453005F247D /* Modern.PokedexDemo.MainView.swift */, - B54269C524ED190C00A66C23 /* Modern.PokedexDemo.ItemView.swift */, + B566C8E724F9D406001134A1 /* Modern.PokedexDemo.ListView.swift */, + B566C8E924F9D412001134A1 /* Modern.PokedexDemo.ListViewController.swift */, + B566C8EB24F9D694001134A1 /* Modern.PokedexDemo.ItemCell.swift */, B5A391B224E96B7400E7E8BD /* Models */, ); path = PokedexDemo; @@ -390,10 +397,9 @@ isa = PBXGroup; children = ( B531EFE824EB5A52005F247D /* Modern.PokedexDemo.PokedexEntry.swift */, + B566C8ED24FA1EA3001134A1 /* Modern.PokedexDemo.PokemonDetails.swift */, B5A391B824E96F8500E7E8BD /* Modern.PokedexDemo.PokemonForm.swift */, B5A391B324E96C0A00E7E8BD /* Modern.PokedexDemo.PokemonDisplay.swift */, - B5A391B524E96C5500E7E8BD /* Modern.PokedexDemo.Move.swift */, - B5A391BC24E977E500E7E8BD /* Modern.PokedexDemo.Ability.swift */, B5A391B724E96E8600E7E8BD /* Attributes */, ); name = Models; @@ -483,8 +489,10 @@ B5A3915924E685EC00E7E8BD /* Classic.swift in Sources */, B5A3918024E787D900E7E8BD /* InstructionsView.swift in Sources */, B5A3917C24E6A76C00E7E8BD /* LazyView.swift in Sources */, + B566C8EC24F9D694001134A1 /* Modern.PokedexDemo.ItemCell.swift in Sources */, B5A3913424E6170500E7E8BD /* Menu.swift in Sources */, B5A3915B24E685FE00E7E8BD /* Modern.swift in Sources */, + B566C8EA24F9D412001134A1 /* Modern.PokedexDemo.ListViewController.swift in Sources */, B5A3911F24E5429200E7E8BD /* SceneDelegate.swift in Sources */, B5A3915324E6537F00E7E8BD /* Menu.ItemView.swift in Sources */, B5A3912124E5429200E7E8BD /* Menu.MainView.swift in Sources */, @@ -504,18 +512,18 @@ B5A3916224E697BA00E7E8BD /* Modern.PlacemarksDemo.MainView.swift in Sources */, B5A3916024E6925900E7E8BD /* Modern.PlacemarksDemo.MapView.swift in Sources */, B5A3916524E698C700E7E8BD /* Modern.PlacemarksDemo.Place.swift in Sources */, - B5A391BD24E977E500E7E8BD /* Modern.PokedexDemo.Ability.swift in Sources */, - B5A391B624E96C5500E7E8BD /* Modern.PokedexDemo.Move.swift in Sources */, - B54269C624ED190C00A66C23 /* Modern.PokedexDemo.ItemView.swift in Sources */, B531EFE924EB5A53005F247D /* Modern.PokedexDemo.PokedexEntry.swift in Sources */, B5A391B924E96F8500E7E8BD /* Modern.PokedexDemo.PokemonForm.swift in Sources */, + B566C8E824F9D406001134A1 /* Modern.PokedexDemo.ListView.swift in Sources */, B5A391B424E96C0A00E7E8BD /* Modern.PokedexDemo.PokemonDisplay.swift in Sources */, B5A391BB24E970A400E7E8BD /* Modern.PokedexDemo.PokemonType.swift in Sources */, B5A3918C24E7B44B00E7E8BD /* Modern.TimeZonesDemo.ItemView.swift in Sources */, + B5E32C9024FA41F9003F46AD /* ImageDownloader.swift in Sources */, B5A3918A24E7AD1800E7E8BD /* Modern.TimeZonesDemo.ListView.swift in Sources */, B5A3918824E7A8F900E7E8BD /* Modern.TimeZonesDemo.MainView.swift in Sources */, B531EFED24EB7453005F247D /* Modern.PokedexDemo.MainView.swift in Sources */, B5A3918624E7A54A00E7E8BD /* Modern.TimeZonesDemo.TimeZone.swift in Sources */, + B566C8EE24FA1EA3001134A1 /* Modern.PokedexDemo.PokemonDetails.swift in Sources */, B5A3919A24E8207A00E7E8BD /* Modern.ColorsDemo.SwiftUI.DetailView.swift in Sources */, B5A3919624E7E4AC00E7E8BD /* Modern.ColorsDemo.SwiftUI.ItemView.swift in Sources */, B5A3919424E7E36700E7E8BD /* Modern.ColorsDemo.SwiftUI.ListView.swift in Sources */, diff --git a/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.Ability.swift b/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.Ability.swift deleted file mode 100644 index 22cde5b..0000000 --- a/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.Ability.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// Demo -// Copyright © 2020 John Rommel Estropia, Inc. All rights reserved. - -import CoreStore - -// MARK: - Modern.PokedexDemo - -extension Modern.PokedexDemo { - - // MARK: - Modern.PokedexDemo.Ability - - final class Ability: CoreStoreObject { - - // MARK: Internal - - @Field.Stored("id") - var id: Int = 0 - - @Field.Stored("name") - var name: String = "" - - @Field.Stored("text") - var text: String = "" - - @Field.Stored("isHiddenAbility") - var isHiddenAbility: Bool = false - - - @Field.Relationship("learners") - var learners: Set - } -} diff --git a/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.ItemCell.swift b/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.ItemCell.swift new file mode 100644 index 0000000..a0040ac --- /dev/null +++ b/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.ItemCell.swift @@ -0,0 +1,386 @@ +// +// Demo +// Copyright © 2020 John Rommel Estropia, Inc. All rights reserved. + +import Combine +import CoreStore +import UIKit + + +// MARK: - Modern.PokedexDemo + +extension Modern.PokedexDemo { + + // MARK: - Modern.PokedexDemo.ItemCell + + final class ItemCell: UICollectionViewCell { + + // MARK: Internal + + static let reuseIdentifier: String = NSStringFromClass(Modern.PokedexDemo.ItemCell.self) + + func setPokedexEntry( + _ pokedexEntry: Modern.PokedexDemo.PokedexEntry, + service: Modern.PokedexDemo.Service + ) { + + guard let pokedexEntry = pokedexEntry.asPublisher() else { + + return self.pokedexEntry = nil + } + guard self.pokedexEntry != pokedexEntry else { + + return + } + self.service = service + self.pokedexEntry = pokedexEntry + + self.didUpdateData(animated: false) + + if let snapshot = pokedexEntry.snapshot { + + service.fetchDetails(for: snapshot) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + + fatalError() + } + + + // MARK: UITableViewCell + + override init(frame: CGRect) { + + super.init(frame: frame) + + let contentView = self.contentView + do { + + contentView.backgroundColor = UIColor.placeholderText.withAlphaComponent(0.1) + contentView.layer.cornerRadius = 10 + contentView.layer.masksToBounds = true + } + + let typesContainerView = UIStackView() + do { + + typesContainerView.translatesAutoresizingMaskIntoConstraints = false + typesContainerView.axis = .horizontal + typesContainerView.alignment = .fill + typesContainerView.distribution = .fillEqually + typesContainerView.spacing = 0 + + typesContainerView.addArrangedSubview(self.type1View) + typesContainerView.addArrangedSubview(self.type2View) + + contentView.addSubview(typesContainerView) + } + + let spriteView = self.spriteView + do { + + spriteView.translatesAutoresizingMaskIntoConstraints = false + spriteView.contentMode = .scaleAspectFill + + contentView.addSubview(spriteView) + } + let placeholderLabel = self.placeholderLabel + do { + + placeholderLabel.translatesAutoresizingMaskIntoConstraints = false + placeholderLabel.textColor = UIColor.placeholderText + placeholderLabel.font = UIFont.systemFont(ofSize: 20, weight: .heavy) + placeholderLabel.numberOfLines = 0 + placeholderLabel.textAlignment = .center + + contentView.addSubview(placeholderLabel) + } + let nameLabel = self.nameLabel + do { + + nameLabel.translatesAutoresizingMaskIntoConstraints = false + nameLabel.font = UIFont.systemFont(ofSize: 14, weight: .bold) + nameLabel.numberOfLines = 0 + nameLabel.textAlignment = .center + + contentView.addSubview(nameLabel) + } + + layout: do { + + NSLayoutConstraint.activate( + [ + typesContainerView.topAnchor.constraint( + equalTo: contentView.topAnchor + ), + typesContainerView.leadingAnchor.constraint( + equalTo: contentView.leadingAnchor + ), + typesContainerView.bottomAnchor.constraint( + equalTo: contentView.bottomAnchor + ), + typesContainerView.trailingAnchor.constraint( + equalTo: contentView.trailingAnchor + ), + + spriteView.topAnchor.constraint( + equalTo: contentView.topAnchor + ), + spriteView.leadingAnchor.constraint( + equalTo: contentView.leadingAnchor + ), + spriteView.bottomAnchor.constraint( + equalTo: contentView.bottomAnchor + ), + spriteView.trailingAnchor.constraint( + equalTo: contentView.trailingAnchor + ), + + placeholderLabel.topAnchor.constraint( + equalTo: contentView.topAnchor, + constant: 10 + ), + placeholderLabel.leadingAnchor.constraint( + equalTo: contentView.leadingAnchor, + constant: 10 + ), + placeholderLabel.bottomAnchor.constraint( + equalTo: contentView.bottomAnchor, + constant: -10 + ), + placeholderLabel.trailingAnchor.constraint( + equalTo: contentView.trailingAnchor, + constant: -10 + ), + + nameLabel.leadingAnchor.constraint( + equalTo: contentView.leadingAnchor, + constant: 10 + ), + nameLabel.bottomAnchor.constraint( + equalTo: contentView.bottomAnchor, + constant: -10 + ), + nameLabel.trailingAnchor.constraint( + equalTo: contentView.trailingAnchor, + constant: -10 + ), + ] + ) + } + } + + override func prepareForReuse() { + + super.prepareForReuse() + + self.service = nil + self.pokedexEntry = nil + self.didUpdateData(animated: false) + } + + + // MARK: Private + + private let spriteView: UIImageView = .init() + private let placeholderLabel: UILabel = .init() + private let nameLabel: UILabel = .init() + private let type1View: UIView = .init() + private let type2View: UIView = .init() + + private var service: Modern.PokedexDemo.Service? + + private var imageURL: URL? { + + didSet { + + let newValue = self.imageURL + guard newValue != oldValue else { + + return + } + self.imageDownloader = ImageDownloader(url: newValue) + } + } + + private var imageDownloader: ImageDownloader = .init(url: nil) { + + didSet { + + let url = self.imageDownloader.url + if url == nil { + + self.spriteView.image = nil + return + } + self.imageDownloader.fetchImage { [weak self] in + + guard let self = self, url == self.imageURL else { + + return + } + self.spriteView.image = $0 + self.spriteView.layer.add(CATransition(), forKey: nil) + } + } + } + + private var pokedexEntry: ObjectPublisher? { + + didSet { + + let newValue = self.pokedexEntry + guard newValue != oldValue else { + + return + } + oldValue?.removeObserver(self) + newValue?.addObserver(self) { [weak self] newValue in + + guard let self = self else { + + return + } + self.pokemonDetails = newValue.snapshot?.$pokemonDetails + + self.didUpdateData(animated: true) + } + + self.pokemonDetails = newValue?.snapshot?.$pokemonDetails + } + } + + private var pokemonDetails: ObjectPublisher? { + + didSet { + + let newValue = self.pokemonDetails + guard newValue != oldValue else { + + return + } + oldValue?.removeObserver(self) + newValue?.addObserver(self) { [weak self] newValue in + + guard let self = self else { + + return + } + let pokemonDetails = newValue.snapshot + self.pokemonForm = pokemonDetails?.$pokemonForm + self.pokemonDisplays = pokemonDetails?.$pokemonDisplays + + self.didUpdateData(animated: true) + } + + let pokemonDetails = newValue?.snapshot + self.pokemonForm = pokemonDetails?.$pokemonForm + self.pokemonDisplays = pokemonDetails?.$pokemonDisplays + } + } + + private var pokemonForm: ObjectPublisher? { + + didSet { + + let newValue = self.pokemonForm + guard newValue != oldValue else { + + return + } + oldValue?.removeObserver(self) + newValue?.addObserver(self) { [weak self] _ in + + self?.didUpdateData(animated: true) + } + } + } + + private var rotationCancellable: AnyCancellable? + private var pokemonDisplays: [ObjectPublisher]? { + + didSet { + + let newValue = self.pokemonDisplays + guard newValue != oldValue else { + + return + } + self.pokemonDisplay = newValue?.first + + self.rotationCancellable = newValue.flatMap { newValue in + + guard !newValue.isEmpty else { + + return nil + } + return Timer + .publish(every: 0.5, on: .main, in: .common) + .autoconnect() + .scan(1, { (index, _) in index + 1 }) + .sink( + receiveValue: { [weak self] (index) in + + guard let self = self else { + + return + } + self.pokemonDisplay = newValue[index % newValue.count] + self.didUpdateData(animated: true) + } + ) + } + } + } + + private var pokemonDisplay: ObjectPublisher? { + + didSet { + + let newValue = self.pokemonDisplay + guard newValue != oldValue else { + + return + } + oldValue?.removeObserver(self) + newValue?.addObserver(self) { [weak self] _ in + + self?.didUpdateData(animated: true) + } + } + } + + private func didUpdateData(animated: Bool) { + + let pokedexEntry = self.pokedexEntry?.snapshot + let pokemonForm = self.pokemonForm?.snapshot + let pokemonDisplay = self.pokemonDisplay?.snapshot + + self.placeholderLabel.text = pokedexEntry?.$id + self.placeholderLabel.isHidden = pokemonForm != nil + + self.type1View.backgroundColor = pokemonForm?.$pokemonType1.color + ?? UIColor.clear + self.type1View.isHidden = pokemonForm == nil + + self.type2View.backgroundColor = pokemonForm?.$pokemonType2?.color + ?? pokemonForm?.$pokemonType1.color + ?? UIColor.clear + self.type2View.isHidden = pokemonForm == nil + + self.nameLabel.text = pokemonDisplay?.$name ?? pokemonForm?.$name + self.nameLabel.isHidden = pokemonDisplay == nil && pokemonForm == nil + + self.imageURL = pokemonDisplay?.$spriteURL + + guard animated else { + + return + } + self.contentView.layer.add(CATransition(), forKey: nil) + } + } +} diff --git a/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.ItemView.swift b/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.ItemView.swift deleted file mode 100644 index 9dd0d6d..0000000 --- a/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.ItemView.swift +++ /dev/null @@ -1,141 +0,0 @@ -// -// Demo -// Copyright © 2020 John Rommel Estropia, Inc. All rights reserved. - -import CoreStore -import SwiftUI - -// MARK: - Modern.PokedexDemo - -extension Modern.PokedexDemo { - - // MARK: - Modern.PokedexDemo.ItemView - - struct ItemView: View { - - // MARK: Internal - - static let preferredHeight: CGFloat = 100 - - init( - pokedexEntry: ObjectPublisher, - service: Modern.PokedexDemo.Service - ) { - - self.pokedexEntry = pokedexEntry - self.service = service - } - - - // MARK: View - - var body: some View { - - let pokedexEntry = self.pokedexEntry.snapshot - let pokemonForm = pokedexEntry?.$pokemonForm?.snapshot - let pokemonDisplay = pokemonForm?.$pokemonDisplay?.snapshot - - return HStack(spacing: 10) { - - LazyView { - - NetworkImageView(url: pokemonDisplay?.$spriteURL) - .frame(width: 70, height: 70) - .id(pokemonDisplay) - } - ZStack { - { () -> AnyView in - if let pokemonForm = pokemonForm { - - return AnyView( - VStack(alignment: .leading) { - - HStack { - Text(pokemonDisplay?.$displayName ?? pokemonForm.$name) - Spacer() - } - HStack { - self.view(for: pokemonForm.$pokemonType1) - pokemonForm.$pokemonType2.map(self.view(for:)) - Spacer() - } - Spacer() - } - ) - } - else { - - return AnyView( - Text(pokedexEntry?.$id ?? "") - .foregroundColor(Color(UIColor.placeholderText)) - .fontWeight(.heavy) - .frame(maxWidth: .infinity) - ) - } - }() - } - .frame(maxWidth: .infinity) - } - .padding() - .onAppear { - - if let pokedexEntry = pokedexEntry { - - self.service.fetchPokemonForm(for: pokedexEntry) - } - } - } - - - // MARK: Private - - @ObservedObject - private var pokedexEntry: ObjectPublisher - - private let service: Modern.PokedexDemo.Service - - private func view(for pokemonType: Modern.PokedexDemo.PokemonType) -> some View { - ZStack { - Color(pokemonType.color) - .cornerRadius(5) - Text(pokemonType.rawValue) - .font(.subheadline) - .fontWeight(.bold) - .foregroundColor(.white) - .padding(.horizontal, 5) - .padding(.vertical, 2) - } - } - } -} - -#if DEBUG - -struct _Demo_Modern_PokedexDemo_ItemView_Preview: PreviewProvider { - - // MARK: PreviewProvider - - static let service = Modern.PokedexDemo.Service() - - static var previews: some View { - - try! Modern.PokedexDemo.dataStack.perform( - synchronous: { transaction in - - guard (try transaction.fetchCount(From())) <= 0 else { - return - } - let pokedexEntry = transaction.create(Into()) - pokedexEntry.id = "bulbasaur" - pokedexEntry.pokemonFormURL = URL(string: "https://pokeapi.co/api/v2/pokemon/1/")! - } - ) - - return Modern.PokedexDemo.ItemView( - pokedexEntry: Modern.PokedexDemo.pokedexEntries.snapshot.first!, - service: Modern.PokedexDemo.Service() - ) - } -} - -#endif diff --git a/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.ListView.swift b/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.ListView.swift new file mode 100644 index 0000000..66ab75f --- /dev/null +++ b/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.ListView.swift @@ -0,0 +1,72 @@ +// +// Demo +// Copyright © 2020 John Rommel Estropia, Inc. All rights reserved. + +import CoreStore +import SwiftUI + +// MARK: - Modern.PokedexDemo + +extension Modern.PokedexDemo { + + // MARK: - Modern.PokedexDemo.ListView + + struct ListView: UIViewControllerRepresentable { + + // MARK: Internal + + init( + service: Modern.PokedexDemo.Service, + listPublisher: ListPublisher + ) { + + self.service = service + self.listPublisher = listPublisher + } + + + // MARK: UIViewControllerRepresentable + + typealias UIViewControllerType = Modern.PokedexDemo.ListViewController + + func makeUIViewController(context: Self.Context) -> UIViewControllerType { + + return UIViewControllerType( + service: self.service, + listPublisher: self.listPublisher + ) + } + + func updateUIViewController(_ uiViewController: UIViewControllerType, context: Self.Context) {} + + static func dismantleUIViewController(_ uiViewController: UIViewControllerType, coordinator: Void) {} + + + // MARK: Private + + @ObservedObject + private var service: Modern.PokedexDemo.Service + + private let listPublisher: ListPublisher + } +} + +#if DEBUG + +struct _Demo_Modern_PokedexDemo_ListView_Preview: PreviewProvider { + + // MARK: PreviewProvider + + static var previews: some View { + + let service = Modern.PokedexDemo.Service() + service.fetchPokedexEntries() + + return Modern.PokedexDemo.ListView( + service: service, + listPublisher: Modern.PokedexDemo.pokedexEntries + ) + } +} + +#endif diff --git a/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.ListViewController.swift b/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.ListViewController.swift new file mode 100644 index 0000000..773445f --- /dev/null +++ b/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.ListViewController.swift @@ -0,0 +1,115 @@ +// +// Demo +// Copyright © 2020 John Rommel Estropia, Inc. All rights reserved. + +import CoreStore +import UIKit + + +// MARK: - Modern.PokedexDemo + +extension Modern.PokedexDemo { + + // MARK: - Modern.PokedexDemo.ListViewController + + final class ListViewController: UICollectionViewController { + + /** + ⭐️ Sample 1: Setting up a `DiffableDataSource.TableViewAdapter` that will manage tableView snapshot updates automatically. We can use the built-in `DiffableDataSource.TableViewAdapter` type directly, but in our case we want to enabled `UITableView` cell deletions so we create a custom subclass `DeletionEnabledDataSource` (see declatation below). + */ + private lazy var dataSource: DiffableDataSource.CollectionViewAdapter = .init( + collectionView: self.collectionView, + dataStack: Modern.PokedexDemo.dataStack, + cellProvider: { (collectionView, indexPath, pokedexEntry) in + + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: Modern.PokedexDemo.ItemCell.reuseIdentifier, + for: indexPath + ) as! Modern.PokedexDemo.ItemCell + cell.setPokedexEntry(pokedexEntry, service: self.service) + return cell + } + ) + + /** + ⭐️ Sample 2: Once the views are created, we can start binding `ListPublisher` updates to the `DiffableDataSource`. We typically call this at the end of `viewDidLoad`. Note that the `addObserver`'s closure argument will only be called on the succeeding updates, so to immediately display the current values, we need to call `dataSource.apply()` once. + */ + private func startObservingList() { + + self.listPublisher.addObserver(self) { (listPublisher) in + + self.dataSource.apply( + listPublisher.snapshot, + animatingDifferences: true + ) + } + self.dataSource.apply( + self.listPublisher.snapshot, + animatingDifferences: false + ) + } + + /** + ⭐️ Sample 3: We can end monitoring updates anytime. `removeObserver()` was called here for illustration purposes only. `ListPublisher`s safely remove deallocated observers automatically. + */ + deinit { + + self.listPublisher.removeObserver(self) + } + + + // MARK: Internal + + init( + service: Modern.PokedexDemo.Service, + listPublisher: ListPublisher + ) { + + self.service = service + self.listPublisher = listPublisher + + let layout = UICollectionViewFlowLayout() + layout.sectionInset = .init( + top: 10, left: 10, bottom: 10, right: 10 + ) + layout.minimumInteritemSpacing = 10 + layout.minimumLineSpacing = 10 + + let screenWidth = UIScreen.main.bounds.inset(by: layout.sectionInset).width + let cellsPerRow: CGFloat = 3 + let cellWidth = floor((screenWidth - ((cellsPerRow - 1) * layout.minimumInteritemSpacing)) / cellsPerRow) + layout.itemSize = .init( + width: cellWidth, + height: ceil(cellWidth * (4 / 3)) + ) + super.init(collectionViewLayout: layout) + } + + + // MARK: UIViewController + + override func viewDidLoad() { + + super.viewDidLoad() + + self.collectionView.register( + Modern.PokedexDemo.ItemCell.self, + forCellWithReuseIdentifier: Modern.PokedexDemo.ItemCell.reuseIdentifier + ) + + self.startObservingList() + } + + + // MARK: Private + + private let service: Modern.PokedexDemo.Service + private let listPublisher: ListPublisher + + @available(*, unavailable) + required init?(coder: NSCoder) { + + fatalError() + } + } +} diff --git a/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.MainView.swift b/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.MainView.swift index 66cdec0..43d0fa6 100644 --- a/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.MainView.swift +++ b/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.MainView.swift @@ -33,15 +33,21 @@ extension Modern.PokedexDemo { var body: some View { let pokedexEntries = self.pokedexEntries.snapshot - let visibleItems = self.visibleItems return ZStack { - + + Modern.PokedexDemo.ListView( + service: self.service, + listPublisher: self.pokedexEntries + ) + .frame(minHeight: 0, maxHeight: .infinity) + .edgesIgnoringSafeArea(.vertical) + if pokedexEntries.isEmpty { - + VStack(alignment: .center, spacing: 20) { Text("This demo needs to make a network connection to download Pokedex entries") if self.service.isLoading { - + Text("Fetching Pokedex…") } else { @@ -49,7 +55,7 @@ extension Modern.PokedexDemo { Button( action: { self.service.fetchPokedexEntries() }, label: { - + Text("Download Pokedex Entries") } ) @@ -57,33 +63,6 @@ extension Modern.PokedexDemo { } .padding() } - else { - - List { - - ForEach(0 ..< min(visibleItems, pokedexEntries.count), id: \.self) { index in - LazyView { - Modern.PokedexDemo.ItemView( - pokedexEntry: pokedexEntries[index], - service: self.service - ) - } - .frame(height: Modern.PokedexDemo.ItemView.preferredHeight) - .frame(minWidth: 0, maxWidth: /*@START_MENU_TOKEN@*/.infinity/*@END_MENU_TOKEN@*/) - } - if visibleItems < pokedexEntries.count { - - Spacer(minLength: Modern.PokedexDemo.ItemView.preferredHeight) - .onAppear { - - self.visibleItems = min( - visibleItems + 50, - pokedexEntries.count - ) - } - } - } - } } .navigationBarTitle("Pokedex") } @@ -93,9 +72,6 @@ extension Modern.PokedexDemo { @ObservedObject private var service: Modern.PokedexDemo.Service = .init() - - @State - private var visibleItems: Int = 50 } } diff --git a/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.Move.swift b/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.Move.swift deleted file mode 100644 index 13cfcf9..0000000 --- a/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.Move.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// Demo -// Copyright © 2020 John Rommel Estropia, Inc. All rights reserved. - -import CoreStore - -// MARK: - Modern.PokedexDemo - -extension Modern.PokedexDemo { - - // MARK: - Modern.PokedexDemo.Move - - final class Move: CoreStoreObject { - - // MARK: Internal - - @Field.Stored("id") - var id: Int = 0 - - @Field.Stored("name") - var name: String = "" - - @Field.Stored("text") - var text: String = "" - - @Field.Stored("pokemonType") - var pokemonType: Modern.PokedexDemo.PokemonType = .normal - - @Field.Stored("power") - var power: Int = 0 - - @Field.Stored("accuracy") - var accuracy: Int = 0 - - @Field.Stored("powerPoints") - var powerPoints: Int = 0 - - @Field.Stored("effectChance") - var effectChance: Int = 0 - - @Field.Stored("priority") - var priority: Int = 0 - - - @Field.Relationship("learners") - var learners: Set - } -} diff --git a/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.PokedexEntry.swift b/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.PokedexEntry.swift index 5f1c8c8..3f940b9 100644 --- a/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.PokedexEntry.swift +++ b/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.PokedexEntry.swift @@ -25,21 +25,21 @@ extension Modern.PokedexDemo { dynamicInitialValue: { URL(string: "data:application/json,%7B%7D")! } ) var pokemonFormURL: URL - - @Field.Stored( - "updateHash", - dynamicInitialValue: { UUID() } - ) - var updateHash: UUID - @Field.Relationship("pokemonForm") - var pokemonForm: Modern.PokedexDemo.PokemonForm? + @Field.Relationship("pokemonDetails") + var pokemonDetails: Modern.PokedexDemo.PokemonDetails? // MARK: ImportableObject typealias ImportSource = (index: Int, json: Dictionary) + + func didInsert(from source: ImportSource, in transaction: BaseDataTransaction) throws { + + self.pokemonDetails = transaction.create(Into()) + try self.update(from: source, in: transaction) + } // MARK: ImportableUniqueObject diff --git a/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.PokemonDetails.swift b/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.PokemonDetails.swift new file mode 100644 index 0000000..23ef673 --- /dev/null +++ b/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.PokemonDetails.swift @@ -0,0 +1,27 @@ +// +// Demo +// Copyright © 2020 John Rommel Estropia, Inc. All rights reserved. + +import CoreStore +import UIKit + +// MARK: - Modern.PokedexDemo + +extension Modern.PokedexDemo { + + // MARK: - Modern.PokedexDemo.PokemonDetails + + final class PokemonDetails: CoreStoreObject { + + // MARK: Internal + + @Field.Relationship("pokedexEntry", inverse: \.$pokemonDetails) + var pokedexEntry: Modern.PokedexDemo.PokedexEntry? + + @Field.Relationship("pokemonForm") + var pokemonForm: Modern.PokedexDemo.PokemonForm? + + @Field.Relationship("pokemonDisplays") + var pokemonDisplays: [Modern.PokedexDemo.PokemonDisplay] + } +} diff --git a/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.PokemonDisplay.swift b/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.PokemonDisplay.swift index ed69659..82e5fa1 100644 --- a/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.PokemonDisplay.swift +++ b/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.PokemonDisplay.swift @@ -18,15 +18,15 @@ extension Modern.PokedexDemo { @Field.Stored("id") var id: Int = 0 - @Field.Stored("displayName") - var displayName: String? + @Field.Stored("name") + var name: String? @Field.Stored("spriteURL") var spriteURL: URL? - - @Field.Relationship("form") - var pokedexForm: Modern.PokedexDemo.PokemonForm? + + @Field.Relationship("pokemonDetails", inverse: \.$pokemonDisplays) + var pokemonDetails: Modern.PokedexDemo.PokemonDetails? // MARK: ImportableObject @@ -57,22 +57,8 @@ extension Modern.PokedexDemo { typealias Service = Modern.PokedexDemo.Service let json = source - for json in try Service.parseJSON(json["names"]) as [Dictionary] { - - let displayName: String = try Service.parseJSON(json["name"]) - let language: String = try Service.parseJSON( - json["language"], - transformer: { (json: Dictionary) in - try Service.parseJSON(json["name"]) - } - ) - switch language { - - case "en": self.displayName = displayName - default: break - } - } - self.spriteURL = try Service.parseJSON( + self.name = try Service.parseJSON(json["name"]) + self.spriteURL = try? Service.parseJSON( json["sprites"], transformer: { (json: Dictionary) in try Service.parseJSON(json["front_default"], transformer: URL.init(string:)) diff --git a/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.PokemonForm.swift b/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.PokemonForm.swift index c277653..0e4b093 100644 --- a/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.PokemonForm.swift +++ b/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.PokemonForm.swift @@ -49,25 +49,15 @@ extension Modern.PokedexDemo { var statSpeed: Int = 0 - @Field.Stored( - "pokemonDisplayURL", - dynamicInitialValue: { URL(string: "data:application/json,%7B%7D")! } + @Field.Coded( + "pokemonDisplayURLs", + coder: FieldCoders.Json.self ) - var pokemonDisplayURL: URL - - - @Field.Relationship("display", inverse: \.$pokedexForm) - var pokemonDisplay: Modern.PokedexDemo.PokemonDisplay? - - @Field.Relationship("abilities", inverse: \.$learners) - var abilities: Set - - @Field.Relationship("moves", inverse: \.$learners) - var moves: Set + var pokemonDisplayURLs: [URL] = [] - @Field.Relationship("pokedexEntry", inverse: \.$pokemonForm) - var pokedexEntry: Modern.PokedexDemo.PokedexEntry? + @Field.Relationship("pokemonDetails", inverse: \.$pokemonForm) + var pokemonDetails: Modern.PokedexDemo.PokemonDetails? // MARK: ImportableObject @@ -139,25 +129,10 @@ extension Modern.PokedexDemo { } } - do { + self.pokemonDisplayURLs = try (Service.parseJSON(json["forms"]) as [Dictionary]).map { json in - let abilities: [Dictionary] = try Service.parseJSON(json["abilities"]) - } - do { - - let moves: [Dictionary] = try Service.parseJSON(json["moves"]) - } - - for json in try Service.parseJSON(json["forms"]) as [Dictionary] { - - let name: String = try Service.parseJSON(json["name"]) let pokemonDisplayURL = try Service.parseJSON(json["url"], transformer: URL.init(string:)) - - guard name == self.name else { - - continue - } - self.pokemonDisplayURL = pokemonDisplayURL + return pokemonDisplayURL } } } diff --git a/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.Service.swift b/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.Service.swift index 933f228..39d471b 100644 --- a/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.Service.swift +++ b/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.Service.swift @@ -128,12 +128,21 @@ extension Modern.PokedexDemo { receiveValue: {} ) } + + func fetchDetails(for pokedexEntry: ObjectSnapshot) { + + self.fetchPokemonFormIfNeeded(for: pokedexEntry) + } - func fetchPokemonForm(for pokedexEntry: ObjectSnapshot) { - - if let pokedexForm = pokedexEntry.$pokemonForm?.snapshot { + private func fetchPokemonFormIfNeeded(for pokedexEntry: ObjectSnapshot) { + + guard let pokemonDetails = pokedexEntry.$pokemonDetails?.snapshot else { - self.fetchPokemonDisplay(for: pokedexForm) + return + } + if let pokedexForm = pokemonDetails.$pokemonForm?.snapshot { + + self.fetchPokemonDisplayIfNeeded(for: pokedexForm) return } self.cancellable["pokemonForm.\(pokedexEntry.$id)"] = URLSession.shared @@ -157,11 +166,7 @@ extension Modern.PokedexDemo { throw Modern.PokedexDemo.Service.Error.unexpected } - if let pokedexEntry = pokedexEntry.asEditable(in: transaction) { - - pokedexForm.pokedexEntry = pokedexEntry - pokedexEntry.updateHash = .init() - } + pokemonDetails.asEditable(in: transaction)?.pokemonForm = pokedexForm return pokedexForm }, success: { pokemonForm in @@ -204,43 +209,80 @@ extension Modern.PokedexDemo { }, receiveValue: { pokemonForm in - self.fetchPokemonDisplay(for: pokemonForm) + self.fetchPokemonDisplayIfNeeded(for: pokemonForm) } ) } - func fetchPokemonDisplay(for pokemonForm: ObjectSnapshot) { - - if let pokemonDisplay = pokemonForm.$pokemonDisplay?.snapshot { + func fetchPokemonDisplayIfNeeded(for pokemonForm: ObjectSnapshot) { + + guard + let pokemonDetails = pokemonForm.$pokemonDetails?.snapshot, + pokemonDetails.$pokemonDisplays.isEmpty + else { return } - self.cancellable["pokemonDisplay.\(pokemonForm.$id)"] = URLSession.shared - .dataTaskPublisher(for: pokemonForm.$pokemonDisplayURL) - .mapError({ .networkError($0) }) + + + + self.cancellable["pokemonDisplay.\(pokemonForm.$id)"] = pokemonForm + .$pokemonDisplayURLs + .map( + { url in + URLSession.shared + .dataTaskPublisher(for: url) + .mapError({ Modern.PokedexDemo.Service.Error.networkError($0) }) + .tryMap( + { output -> Dictionary in + + try Self.parseJSON( + try JSONSerialization.jsonObject(with: output.data, options: []) + ) + } + ) + .mapError( + { error -> Modern.PokedexDemo.Service.Error in + switch error { + + case let error as Modern.PokedexDemo.Service.Error: + return error + + case let error: + return Modern.PokedexDemo.Service.Error.otherError(error) + } + } + ) + .eraseToAnyPublisher() + } + ) + .reduce( + into: Just<[Dictionary]>([]) + .setFailureType(to: Modern.PokedexDemo.Service.Error.self) + .eraseToAnyPublisher(), + { (result, publisher) in + result = result + .zip(publisher, { $0 + [$1] }) + .eraseToAnyPublisher() + } + ) .flatMap( - { output in + { outputs in return Future { promise in Modern.PokedexDemo.dataStack.perform( asynchronous: { transaction -> Void in - let json: Dictionary = try Self.parseJSON( - try JSONSerialization.jsonObject(with: output.data, options: []) - ) - guard let pokemonDisplay = try transaction.importUniqueObject( + let pokemonDisplays = try transaction.importUniqueObjects( Into(), - source: json - ) else { + sourceArray: outputs + ) + guard !pokemonDisplays.isEmpty else { throw Modern.PokedexDemo.Service.Error.unexpected } - if let pokemonForm = pokemonForm.asEditable(in: transaction) { - - pokemonDisplay.pokedexForm = pokemonForm - pokemonForm.pokedexEntry?.updateHash = .init() - } + pokemonDetails.asEditable(in: transaction)?.pokemonDisplays = pokemonDisplays }, success: { diff --git a/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.swift b/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.swift index 76b6e36..0e65d0b 100644 --- a/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.swift +++ b/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.swift @@ -24,10 +24,9 @@ extension Modern { modelVersion: "V1", entities: [ Entity("PokedexEntry"), + Entity("PokemonDetails"), Entity("PokemonForm"), - Entity("PokemonDisplay"), - Entity("Move"), - Entity("Ability") + Entity("PokemonDisplay") ] ) ) diff --git a/Demo/Sources/Helpers/ImageDownloader.swift b/Demo/Sources/Helpers/ImageDownloader.swift new file mode 100644 index 0000000..7e80e3e --- /dev/null +++ b/Demo/Sources/Helpers/ImageDownloader.swift @@ -0,0 +1,72 @@ +// +// ImageDownloader.swift +// Demo +// +// Created by John Rommel Estropia on 2020/08/29. +// + +import Foundation +import UIKit +import Combine + +// MARK: - ImageDownloader + +final class ImageDownloader: ObservableObject { + + // MARK: FilePrivate + + private(set) var image: UIImage? + + let url: URL? + + init(url: URL?) { + + self.url = url + guard let url = url else { + + return + } + if let image = Self.cache[url] { + + self.image = image + } + } + + func fetchImage(completion: @escaping (UIImage) -> Void = { _ in }) { + + guard let url = url else { + + return + } + if let image = Self.cache[url] { + + self.objectWillChange.send() + self.image = image + completion(image) + return + } + self.cancellable = URLSession.shared + .dataTaskPublisher(for: url) + .receive(on: DispatchQueue.main) + .sink( + receiveCompletion: { _ in }, + receiveValue: { output in + + if let image = UIImage(data: output.data) { + + Self.cache[url] = image + self.objectWillChange.send() + self.image = image + completion(image) + } + } + ) + } + + + // MARK: Private + + private static var cache: [URL: UIImage] = [:] + + private var cancellable: AnyCancellable? +} diff --git a/Demo/Sources/Helpers/NetworkImageView.swift b/Demo/Sources/Helpers/NetworkImageView.swift index 184d370..5094493 100644 --- a/Demo/Sources/Helpers/NetworkImageView.swift +++ b/Demo/Sources/Helpers/NetworkImageView.swift @@ -46,67 +46,6 @@ struct NetworkImageView: View { // MARK: Private @ObservedObject - private var imageDownloader: ImageDownloader - - - // MARK: - NetworkImageView.ImageDownloader - - fileprivate final class ImageDownloader: ObservableObject { - - // MARK: FilePrivate - - private(set) var image: UIImage? - - let url: URL? - - init(url: URL?) { - - self.url = url - guard let url = url else { - - return - } - if let image = Self.cache[url] { - - self.image = image - } - } - - func fetchImage() { - - guard let url = url else { - - return - } - if let image = Self.cache[url] { - - self.objectWillChange.send() - self.image = image - return - } - self.cancellable = URLSession.shared - .dataTaskPublisher(for: url) - .receive(on: DispatchQueue.main) - .sink( - receiveCompletion: { _ in }, - receiveValue: { output in - - if let image = UIImage(data: output.data) { - - Self.cache[url] = image - self.objectWillChange.send() - self.image = image - } - } - ) - } - - - // MARK: Private - - private static var cache: [URL: UIImage] = [:] - - private var cancellable: AnyCancellable? - } + private var imageDownloader: ImageDownloader } diff --git a/Sources/ListPublisher.swift b/Sources/ListPublisher.swift index 1b80ca6..b2a48cf 100644 --- a/Sources/ListPublisher.swift +++ b/Sources/ListPublisher.swift @@ -113,9 +113,14 @@ public final class ListPublisher: Hashable { Calling `addObserver(_:_:)` multiple times on the same observer is safe. - parameter observer: an object to become owner of the specified `callback` + - parameter notifyInitial: if `true`, the callback is executed immediately with the current publisher state. Otherwise only succeeding updates will notify the observer. Default value is `false`. - parameter callback: the closure to execute when changes occur */ - public func addObserver(_ observer: T, _ callback: @escaping (ListPublisher) -> Void) { + public func addObserver( + _ observer: T, + notifyInitial: Bool = false, + _ callback: @escaping (ListPublisher) -> Void + ) { Internals.assert( Thread.isMainThread, @@ -125,6 +130,10 @@ public final class ListPublisher: Hashable { Internals.Closure(callback), forKey: observer ) + if notifyInitial { + + callback(self) + } } /** diff --git a/Sources/ObjectPublisher.swift b/Sources/ObjectPublisher.swift index e0c7458..236914b 100644 --- a/Sources/ObjectPublisher.swift +++ b/Sources/ObjectPublisher.swift @@ -83,9 +83,14 @@ public final class ObjectPublisher: ObjectRepresentation, Hash Calling `addObserver(_:_:)` multiple times on the same observer is safe. - parameter observer: an object to become owner of the specified `callback` + - parameter notifyInitial: if `true`, the callback is executed immediately with the current publisher state. Otherwise only succeeding updates will notify the observer. Default value is `false`. - parameter callback: the closure to execute when changes occur */ - public func addObserver(_ observer: T, _ callback: @escaping (ObjectPublisher) -> Void) { + public func addObserver( + _ observer: T, + notifyInitial: Bool = false, + _ callback: @escaping (ObjectPublisher) -> Void + ) { Internals.assert( Thread.isMainThread, @@ -96,6 +101,11 @@ public final class ObjectPublisher: ObjectRepresentation, Hash forKey: observer ) _ = self.lazySnapshot + + if notifyInitial { + + callback(self) + } } /**