1343 lines
49 KiB
Swift
1343 lines
49 KiB
Swift
//TODO: Implement support for bonus chapters
|
|
//TODO: Implement Anilist support?
|
|
//TODO: Properly avoid swallowing of input from UICollectionView used for scrolling
|
|
//TODO: Convert between state for normal and scrolling page turn
|
|
|
|
import UIKit
|
|
|
|
let preloadCount = 2
|
|
// With a whopping 2 cores, and 2 threads because no hyperthreading, it makes sense to only have a queue as an extra thread to do work with to allow the main thread for ui.
|
|
let queue = DispatchQueue(label: "queue", qos: .utility)
|
|
|
|
enum PageTurn {
|
|
case next
|
|
case previous
|
|
}
|
|
|
|
enum ReadProgress: Codable {
|
|
case leftToRight(chapter: Int, page: Int)
|
|
case rightToLeft(chapter: Int, page: Int)
|
|
case scroll(CGPoint)
|
|
}
|
|
|
|
enum PageTurnMode: Codable {
|
|
case leftToRight
|
|
case rightToLeft
|
|
case scroll
|
|
}
|
|
|
|
struct Comic {
|
|
var cover: UIImage
|
|
var metadata: Metadata
|
|
var path: URL
|
|
}
|
|
|
|
struct GlobalState: Codable {
|
|
var comicName: String? = nil
|
|
}
|
|
|
|
struct LocalState: Codable {
|
|
var progress: ReadProgress
|
|
var backgroundColor: String = "black"
|
|
}
|
|
|
|
struct Metadata: Decodable {
|
|
var title: String
|
|
var original_language: String
|
|
var last_volume: Float
|
|
var last_chapter: Float
|
|
var publication_demographic: String
|
|
var status: String
|
|
var year: Int?
|
|
var content_rating: String
|
|
var state: String
|
|
var created_at: String
|
|
var updated_at: String
|
|
var chapters: [ChapterMetadata]
|
|
}
|
|
|
|
struct ChapterMetadata: Decodable {
|
|
var chapter: Float
|
|
var volume: Float
|
|
var title: String?
|
|
var pages: Int
|
|
}
|
|
|
|
class ViewController: UIViewController, UIGestureRecognizerDelegate {
|
|
var homeView = UIView()
|
|
var readerView = UIView()
|
|
|
|
@IBOutlet var scrollingCollectionView: UICollectionView!
|
|
var scrollPos: CGPoint!
|
|
var hasSetContentOffset = false
|
|
var pageCount = 0
|
|
var pagesAvailable = 0
|
|
var sizeList: [CGSize] = []
|
|
var chapterAndPages: [(Int, Int)] = []
|
|
|
|
var imageView = UIImageView()
|
|
var mode = PageTurnMode.leftToRight
|
|
var metadata: Metadata!
|
|
var currentPage: Int! = nil
|
|
var currentChapter: Int! = nil
|
|
var currentPath: URL!
|
|
|
|
var leftTap: UITapGestureRecognizer!
|
|
var rightTap: UITapGestureRecognizer!
|
|
var topTap: UITapGestureRecognizer!
|
|
let leftView = UIView()
|
|
let rightView = UIView()
|
|
let topView = UIView()
|
|
|
|
let topBarView = UIView()
|
|
let bottomBarView = UIView()
|
|
let info = UILabel()
|
|
|
|
let pageTurnDropdownView = UIView()
|
|
let pageTurnDropdownButton = UIButton()
|
|
|
|
let backgroundColorDropdownView = UIView()
|
|
let backgroundColorDropdownButton = UIButton()
|
|
|
|
let homeButton = UIButton()
|
|
|
|
let fileManager = FileManager.default
|
|
var globalState = getGlobalState()
|
|
|
|
let documentsURL = getDocumentsURL().unsafelyUnwrapped
|
|
|
|
var comics: [Comic] = []
|
|
@IBOutlet var comicCollectionView: UICollectionView!
|
|
|
|
let imageLoader = ImageLoader.init()
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
|
|
view.backgroundColor = .white
|
|
|
|
setup()
|
|
}
|
|
|
|
func setup() {
|
|
do {
|
|
if try fileManager.contentsOfDirectory(atPath: documentsURL.path).isEmpty {
|
|
saveGlobalState()
|
|
}
|
|
} catch {
|
|
saveGlobalState()
|
|
}
|
|
|
|
homeView.translatesAutoresizingMaskIntoConstraints = false
|
|
readerView.translatesAutoresizingMaskIntoConstraints = false
|
|
readerView.isHidden = true
|
|
readerView.backgroundColor = .black
|
|
homeView.isHidden = false
|
|
view.addSubview(homeView)
|
|
view.addSubview(readerView)
|
|
NSLayoutConstraint.activate([
|
|
homeView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
homeView.topAnchor.constraint(equalTo: view.topAnchor),
|
|
homeView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
|
homeView.widthAnchor.constraint(equalTo: view.widthAnchor),
|
|
|
|
readerView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
readerView.topAnchor.constraint(equalTo: view.topAnchor),
|
|
readerView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
|
readerView.widthAnchor.constraint(equalTo: view.widthAnchor),
|
|
])
|
|
loadComics()
|
|
setupImageView()
|
|
setupScrollingCollectionView()
|
|
setupGestures()
|
|
setupBar()
|
|
setupHomeView()
|
|
if let name = globalState.comicName {
|
|
readComic(name: name)
|
|
}
|
|
}
|
|
|
|
func setupHomeView() {
|
|
let layout = UICollectionViewFlowLayout()
|
|
let spacing: CGFloat = 8
|
|
layout.minimumInteritemSpacing = spacing
|
|
layout.minimumLineSpacing = spacing
|
|
|
|
comicCollectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout)
|
|
comicCollectionView.translatesAutoresizingMaskIntoConstraints = false
|
|
comicCollectionView.dataSource = self
|
|
comicCollectionView.delegate = self
|
|
comicCollectionView.backgroundColor = .white
|
|
comicCollectionView.register(
|
|
ComicImageCell.self, forCellWithReuseIdentifier: "ComicImageCell")
|
|
homeView.addSubview(comicCollectionView)
|
|
}
|
|
|
|
func loadComics() {
|
|
do {
|
|
var directories: [URL] = []
|
|
|
|
let contents = try fileManager.contentsOfDirectory(
|
|
at: documentsURL,
|
|
includingPropertiesForKeys: [.isDirectoryKey],
|
|
options: [.skipsHiddenFiles]
|
|
)
|
|
|
|
directories = contents.filter { url in
|
|
var isDirectory: ObjCBool = false
|
|
return fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory)
|
|
&& isDirectory.boolValue
|
|
}.sorted { $0.path < $1.path }
|
|
|
|
for dir in directories {
|
|
comics.append(
|
|
Comic(
|
|
cover:
|
|
UIImage(
|
|
contentsOfFile: dir.appendingPathComponent("cover.jpg").path)!,
|
|
metadata: try JSONDecoder().decode(
|
|
Metadata.self,
|
|
from:
|
|
Data(
|
|
try String(
|
|
contentsOfFile: dir.appendingPathComponent(
|
|
"metadata.json"
|
|
)
|
|
.path
|
|
).utf8),
|
|
),
|
|
path: dir,
|
|
))
|
|
}
|
|
} catch {
|
|
print("Failed to read directories")
|
|
}
|
|
}
|
|
|
|
func saveGlobalState() {
|
|
do {
|
|
try JSONEncoder().encode(self.globalState).write(
|
|
to: documentsURL.appendingPathComponent("state.json"))
|
|
} catch {
|
|
print("failed to save global state")
|
|
}
|
|
}
|
|
|
|
func convertColorToString(color: UIColor) -> String {
|
|
return switch color {
|
|
case .white: "white"
|
|
case .gray: "gray"
|
|
case .black: "black"
|
|
case .red: "red"
|
|
case .blue: "blue"
|
|
default: "black"
|
|
}
|
|
}
|
|
|
|
func convertStringToColor(str: String) -> UIColor {
|
|
return switch str {
|
|
case "white": .white
|
|
case "gray": .gray
|
|
case "black": .black
|
|
case "red": .red
|
|
case "blue": .blue
|
|
default: .black
|
|
}
|
|
}
|
|
|
|
func saveLocalState() {
|
|
let color = readerView.backgroundColor ?? .black
|
|
let progress: ReadProgress =
|
|
switch self.mode {
|
|
case .leftToRight:
|
|
ReadProgress.leftToRight(chapter: self.currentChapter, page: self.currentPage)
|
|
case .rightToLeft:
|
|
ReadProgress.rightToLeft(chapter: self.currentChapter, page: self.currentPage)
|
|
case .scroll: ReadProgress.scroll(scrollingCollectionView.contentOffset)
|
|
}
|
|
queue.async {
|
|
do {
|
|
try JSONEncoder().encode(
|
|
LocalState(
|
|
progress: progress, backgroundColor: self.convertColorToString(color: color)
|
|
)
|
|
).write(
|
|
to: self.currentPath.appendingPathComponent("state.json"))
|
|
} catch {
|
|
print("failed to save local state")
|
|
}
|
|
}
|
|
}
|
|
|
|
func loadLocalState() {
|
|
do {
|
|
let json = Data(
|
|
try String(
|
|
contentsOfFile: currentPath.appendingPathComponent("state.json").path
|
|
).utf8)
|
|
let local = try JSONDecoder().decode(LocalState.self, from: json)
|
|
switch local.progress {
|
|
case .leftToRight(let chapter, let page):
|
|
self.currentChapter = chapter
|
|
self.currentPage = page
|
|
self.mode = .leftToRight
|
|
case .rightToLeft(let chapter, let page):
|
|
self.currentChapter = chapter
|
|
self.currentPage = page
|
|
self.mode = .rightToLeft
|
|
case .scroll(let point):
|
|
if self.scrollPos == nil {
|
|
self.scrollPos = point
|
|
let centerPoint = CGPoint(
|
|
x: scrollingCollectionView.bounds.midX
|
|
+ scrollingCollectionView.contentOffset.x,
|
|
y: scrollingCollectionView.bounds.midY
|
|
+ scrollingCollectionView.contentOffset.y)
|
|
|
|
if let indexPath = scrollingCollectionView.indexPathForItem(at: centerPoint) {
|
|
let (chapter, page) = chapterAndPages[indexPath.item]
|
|
currentChapter = chapter
|
|
currentPage = page
|
|
}
|
|
}
|
|
self.mode = .scroll
|
|
}
|
|
readerView.backgroundColor = convertStringToColor(str: local.backgroundColor)
|
|
} catch {
|
|
print("failed to load local state")
|
|
}
|
|
}
|
|
|
|
func setupGestures() {
|
|
let swipeLeft = UISwipeGestureRecognizer(target: self, action: #selector(handleSwipe(_:)))
|
|
swipeLeft.direction = .left
|
|
readerView.addGestureRecognizer(swipeLeft)
|
|
|
|
let swipeRight = UISwipeGestureRecognizer(target: self, action: #selector(handleSwipe(_:)))
|
|
swipeRight.direction = .right
|
|
readerView.addGestureRecognizer(swipeRight)
|
|
|
|
setupTapZones()
|
|
}
|
|
|
|
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
|
if scrollingCollectionView.isHidden { return }
|
|
let centerPoint = CGPoint(
|
|
x: scrollingCollectionView.bounds.midX + scrollingCollectionView.contentOffset.x,
|
|
y: scrollingCollectionView.bounds.midY + scrollingCollectionView.contentOffset.y)
|
|
|
|
if let indexPath = scrollingCollectionView.indexPathForItem(at: centerPoint) {
|
|
let (chapter, page) = chapterAndPages[indexPath.item]
|
|
currentChapter = chapter
|
|
currentPage = page
|
|
}
|
|
if scrollingCollectionView.isHidden == false {
|
|
saveLocalState()
|
|
}
|
|
}
|
|
|
|
override func viewDidLayoutSubviews() {
|
|
super.viewDidLayoutSubviews()
|
|
if scrollingCollectionView.isHidden == false && mode == .scroll
|
|
&& !hasSetContentOffset
|
|
{
|
|
scrollingCollectionView.setContentOffset(scrollPos, animated: false)
|
|
hasSetContentOffset = true
|
|
}
|
|
}
|
|
|
|
func readComic(name: String) {
|
|
readerView.isHidden = false
|
|
homeView.isHidden = true
|
|
setNeedsStatusBarAppearanceUpdate()
|
|
if let path = getPathFromComicName(name: name) {
|
|
currentPath = path
|
|
metadata = getMetadata(path: path)!
|
|
|
|
globalState.comicName = metadata.title
|
|
saveGlobalState()
|
|
loadLocalState()
|
|
if mode != .scroll {
|
|
scrollingCollectionView.isHidden = true
|
|
leftView.isHidden = false
|
|
rightView.isHidden = false
|
|
setImages(path: path, metadata: metadata)
|
|
} else {
|
|
setupScrolling()
|
|
}
|
|
}
|
|
}
|
|
|
|
func setupScrolling() {
|
|
leftView.isHidden = true
|
|
rightView.isHidden = true
|
|
scrollingCollectionView.isHidden = false
|
|
pagesAvailable = countFiles()
|
|
var index = 0
|
|
var (chapter, page) = (1, 1)
|
|
chapterAndPages = Array(repeating: (0, 0), count: pagesAvailable + 1)
|
|
chapterAndPages[index] = (chapter, page)
|
|
while index < pagesAvailable {
|
|
index += 1
|
|
(chapter, page) = getChapterAndPageFromTurn(
|
|
chapter: chapter, page: page, turn: .next)
|
|
chapterAndPages[index] = (chapter, page)
|
|
}
|
|
|
|
sizeList = Array(repeating: CGSize(), count: pagesAvailable)
|
|
let binPath = currentPath.appendingPathComponent("size.bin")
|
|
let decoder = PropertyListDecoder()
|
|
do {
|
|
let data = try Data(contentsOf: binPath)
|
|
sizeList = try decoder.decode([CGSize].self, from: data)
|
|
} catch {
|
|
print("Failed to read data:", error)
|
|
}
|
|
|
|
scrollingCollectionView.reloadData()
|
|
DispatchQueue.main.async {
|
|
let encoder = PropertyListEncoder()
|
|
encoder.outputFormat = .binary
|
|
do {
|
|
let data = try encoder.encode(self.sizeList)
|
|
try data.write(to: binPath)
|
|
} catch {
|
|
print("Encoding or writing failed:", error)
|
|
}
|
|
}
|
|
}
|
|
|
|
func countFiles() -> Int {
|
|
var count = 0
|
|
if let enumerator = fileManager.enumerator(
|
|
at: currentPath, includingPropertiesForKeys: [.isDirectoryKey],
|
|
options: [.skipsHiddenFiles])
|
|
{
|
|
for case let dir as URL in enumerator {
|
|
do {
|
|
if let enumerator = fileManager.enumerator(
|
|
at: dir, includingPropertiesForKeys: [.isRegularFileKey],
|
|
options: [.skipsHiddenFiles])
|
|
{
|
|
for case let file as URL in enumerator {
|
|
let resourceValues = try file.resourceValues(forKeys: [
|
|
.isRegularFileKey
|
|
])
|
|
if resourceValues.isRegularFile == true {
|
|
count += 1
|
|
}
|
|
}
|
|
}
|
|
|
|
} catch {
|
|
print("Error reading file attributes for \(dir):", error)
|
|
}
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
func setupScrollingCollectionView() {
|
|
let layout = UICollectionViewFlowLayout()
|
|
layout.minimumInteritemSpacing = 0
|
|
layout.minimumLineSpacing = 0
|
|
scrollingCollectionView = UICollectionView(
|
|
frame: view.bounds, collectionViewLayout: layout)
|
|
scrollingCollectionView.translatesAutoresizingMaskIntoConstraints = false
|
|
scrollingCollectionView.dataSource = self
|
|
scrollingCollectionView.delegate = self
|
|
scrollingCollectionView.backgroundColor = .white
|
|
scrollingCollectionView.isHidden = true
|
|
scrollingCollectionView.register(
|
|
ScrollingImageCell.self, forCellWithReuseIdentifier: "ScrollingImageCell")
|
|
readerView.addSubview(scrollingCollectionView)
|
|
|
|
NSLayoutConstraint.activate([
|
|
scrollingCollectionView.topAnchor.constraint(equalTo: readerView.topAnchor),
|
|
scrollingCollectionView.leadingAnchor.constraint(equalTo: readerView.leadingAnchor),
|
|
scrollingCollectionView.trailingAnchor.constraint(equalTo: readerView.trailingAnchor),
|
|
scrollingCollectionView.heightAnchor.constraint(equalTo: readerView.heightAnchor),
|
|
])
|
|
}
|
|
|
|
func setupTapZones() {
|
|
leftView.translatesAutoresizingMaskIntoConstraints = false
|
|
rightView.translatesAutoresizingMaskIntoConstraints = false
|
|
topView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
readerView.addSubview(leftView)
|
|
readerView.addSubview(rightView)
|
|
readerView.addSubview(topView)
|
|
|
|
NSLayoutConstraint.activate([
|
|
leftView.leadingAnchor.constraint(equalTo: readerView.leadingAnchor),
|
|
leftView.topAnchor.constraint(equalTo: readerView.topAnchor),
|
|
leftView.bottomAnchor.constraint(equalTo: readerView.bottomAnchor),
|
|
leftView.widthAnchor.constraint(equalTo: readerView.widthAnchor, multiplier: 0.5),
|
|
|
|
rightView.trailingAnchor.constraint(equalTo: readerView.trailingAnchor),
|
|
rightView.topAnchor.constraint(equalTo: readerView.topAnchor),
|
|
rightView.bottomAnchor.constraint(equalTo: readerView.bottomAnchor),
|
|
rightView.widthAnchor.constraint(equalTo: readerView.widthAnchor, multiplier: 0.5),
|
|
|
|
topView.topAnchor.constraint(equalTo: readerView.topAnchor),
|
|
topView.leadingAnchor.constraint(equalTo: readerView.leadingAnchor),
|
|
topView.trailingAnchor.constraint(equalTo: readerView.trailingAnchor),
|
|
topView.heightAnchor.constraint(equalTo: readerView.heightAnchor, multiplier: 0.2),
|
|
])
|
|
|
|
leftView.backgroundColor = .clear
|
|
rightView.backgroundColor = .clear
|
|
topView.backgroundColor = .clear
|
|
|
|
leftTap = UITapGestureRecognizer(target: self, action: #selector(handleLeftTap))
|
|
rightTap = UITapGestureRecognizer(target: self, action: #selector(handleRightTap))
|
|
topTap = UITapGestureRecognizer(target: self, action: #selector(handleTopTap))
|
|
|
|
leftTap.delegate = self
|
|
rightTap.delegate = self
|
|
topTap.delegate = self
|
|
|
|
leftView.addGestureRecognizer(leftTap)
|
|
rightView.addGestureRecognizer(rightTap)
|
|
topView.addGestureRecognizer(topTap)
|
|
}
|
|
|
|
func setupBar() {
|
|
topBarView.translatesAutoresizingMaskIntoConstraints = false
|
|
topBarView.backgroundColor = UIColor.black.withAlphaComponent(0.8)
|
|
topBarView.isHidden = true
|
|
readerView.addSubview(topBarView)
|
|
|
|
bottomBarView.translatesAutoresizingMaskIntoConstraints = false
|
|
bottomBarView.backgroundColor = UIColor.black.withAlphaComponent(0.8)
|
|
bottomBarView.isHidden = true
|
|
readerView.addSubview(bottomBarView)
|
|
|
|
NSLayoutConstraint.activate([
|
|
topBarView.topAnchor.constraint(equalTo: readerView.safeAreaLayoutGuide.topAnchor),
|
|
topBarView.leadingAnchor.constraint(equalTo: readerView.leadingAnchor),
|
|
topBarView.trailingAnchor.constraint(equalTo: readerView.trailingAnchor),
|
|
topBarView.heightAnchor.constraint(equalToConstant: 64),
|
|
|
|
bottomBarView.bottomAnchor.constraint(equalTo: readerView.bottomAnchor),
|
|
bottomBarView.leadingAnchor.constraint(equalTo: readerView.leadingAnchor),
|
|
bottomBarView.trailingAnchor.constraint(equalTo: readerView.trailingAnchor),
|
|
bottomBarView.heightAnchor.constraint(equalToConstant: 128),
|
|
])
|
|
|
|
setupBackgroundColorDropdown()
|
|
setupPageTurnDropdown()
|
|
setupHomeButton()
|
|
setupBottomBarInfo()
|
|
}
|
|
|
|
func setupBottomBarInfo() {
|
|
info.textAlignment = .center
|
|
info.numberOfLines = 6
|
|
info.translatesAutoresizingMaskIntoConstraints = false
|
|
info.textColor = .white
|
|
bottomBarView.addSubview(info)
|
|
NSLayoutConstraint.activate([
|
|
info.topAnchor.constraint(equalTo: bottomBarView.topAnchor),
|
|
info.bottomAnchor.constraint(equalTo: bottomBarView.bottomAnchor),
|
|
info.leadingAnchor.constraint(equalTo: bottomBarView.leadingAnchor),
|
|
info.trailingAnchor.constraint(equalTo: bottomBarView.trailingAnchor),
|
|
info.centerXAnchor.constraint(equalTo: bottomBarView.centerXAnchor),
|
|
info.centerYAnchor.constraint(equalTo: bottomBarView.centerYAnchor),
|
|
])
|
|
}
|
|
|
|
func setupHomeButton() {
|
|
homeButton.setTitle("Home", for: .normal)
|
|
homeButton.setTitleColor(.white, for: .normal)
|
|
homeButton.translatesAutoresizingMaskIntoConstraints = false
|
|
topBarView.addSubview(homeButton)
|
|
|
|
homeButton.addTarget(
|
|
self, action: #selector(goHome), for: .touchDown
|
|
)
|
|
NSLayoutConstraint.activate([
|
|
homeButton.trailingAnchor.constraint(equalTo: topBarView.trailingAnchor, constant: -32),
|
|
homeButton.centerYAnchor.constraint(equalTo: topBarView.centerYAnchor),
|
|
homeButton.topAnchor.constraint(equalTo: topBarView.topAnchor),
|
|
homeButton.bottomAnchor.constraint(equalTo: topBarView.bottomAnchor),
|
|
])
|
|
}
|
|
|
|
override var prefersStatusBarHidden: Bool {
|
|
return homeView.isHidden
|
|
}
|
|
|
|
@objc func goHome() {
|
|
readerView.isHidden = true
|
|
homeView.isHidden = false
|
|
globalState.comicName = nil
|
|
hideBar()
|
|
|
|
setNeedsStatusBarAppearanceUpdate()
|
|
saveGlobalState()
|
|
}
|
|
|
|
func setupBackgroundColorDropdown() {
|
|
backgroundColorDropdownButton.setTitle("Background color ▼", for: .normal)
|
|
backgroundColorDropdownButton.setTitleColor(.white, for: .normal)
|
|
backgroundColorDropdownButton.translatesAutoresizingMaskIntoConstraints = false
|
|
topBarView.addSubview(backgroundColorDropdownButton)
|
|
|
|
backgroundColorDropdownButton.addTarget(
|
|
self, action: #selector(toggleBackgroundColorDropdown), for: .touchDown
|
|
)
|
|
|
|
backgroundColorDropdownView.backgroundColor = UIColor.darkGray
|
|
backgroundColorDropdownView.translatesAutoresizingMaskIntoConstraints = false
|
|
backgroundColorDropdownView.isHidden = true
|
|
readerView.addSubview(backgroundColorDropdownView)
|
|
|
|
let stackView = UIStackView()
|
|
stackView.axis = .vertical
|
|
stackView.spacing = 8
|
|
stackView.distribution = .fillEqually
|
|
stackView.translatesAutoresizingMaskIntoConstraints = false
|
|
stackView.isLayoutMarginsRelativeArrangement = true
|
|
stackView.layoutMargins = UIEdgeInsets(top: 8, left: 0, bottom: 8, right: 0)
|
|
backgroundColorDropdownView.addSubview(stackView)
|
|
|
|
let colorOptions = ["White", "Gray", "Black", "Red", "Blue"]
|
|
for title in colorOptions {
|
|
let button = UIButton(type: .system)
|
|
button.setTitle(title, for: .normal)
|
|
button.setTitleColor(.white, for: .normal)
|
|
button.contentHorizontalAlignment = .left
|
|
button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 0)
|
|
button.backgroundColor = .clear
|
|
button.translatesAutoresizingMaskIntoConstraints = false
|
|
button.addTarget(
|
|
self, action: #selector(handleBackgroundColorOption), for: .touchDown
|
|
)
|
|
stackView.addArrangedSubview(button)
|
|
}
|
|
|
|
NSLayoutConstraint.activate([
|
|
backgroundColorDropdownButton.leadingAnchor.constraint(
|
|
equalTo: topBarView.leadingAnchor, constant: 32
|
|
),
|
|
backgroundColorDropdownButton.centerYAnchor.constraint(
|
|
equalTo: topBarView.centerYAnchor),
|
|
backgroundColorDropdownButton.topAnchor.constraint(
|
|
equalTo: topBarView.topAnchor),
|
|
backgroundColorDropdownButton.bottomAnchor.constraint(
|
|
equalTo: topBarView.bottomAnchor),
|
|
|
|
backgroundColorDropdownView.topAnchor.constraint(
|
|
equalTo: backgroundColorDropdownButton.bottomAnchor),
|
|
backgroundColorDropdownView.leadingAnchor.constraint(
|
|
equalTo: backgroundColorDropdownButton.leadingAnchor),
|
|
backgroundColorDropdownView.trailingAnchor.constraint(
|
|
equalTo: backgroundColorDropdownButton.trailingAnchor),
|
|
|
|
stackView.topAnchor.constraint(
|
|
equalTo: backgroundColorDropdownView.topAnchor),
|
|
stackView.bottomAnchor.constraint(equalTo: backgroundColorDropdownView.bottomAnchor),
|
|
stackView.leadingAnchor.constraint(equalTo: backgroundColorDropdownView.leadingAnchor),
|
|
stackView.trailingAnchor.constraint(
|
|
equalTo: backgroundColorDropdownView.trailingAnchor),
|
|
])
|
|
}
|
|
|
|
func setupPageTurnDropdown() {
|
|
pageTurnDropdownButton.setTitle("Page Turn Mode ▼", for: .normal)
|
|
pageTurnDropdownButton.setTitleColor(.white, for: .normal)
|
|
pageTurnDropdownButton.translatesAutoresizingMaskIntoConstraints = false
|
|
topBarView.addSubview(pageTurnDropdownButton)
|
|
|
|
pageTurnDropdownButton.addTarget(
|
|
self, action: #selector(togglePageTurnDropdown), for: .touchDown
|
|
)
|
|
|
|
pageTurnDropdownView.backgroundColor = UIColor.darkGray
|
|
pageTurnDropdownView.translatesAutoresizingMaskIntoConstraints = false
|
|
pageTurnDropdownView.isHidden = true
|
|
readerView.addSubview(pageTurnDropdownView)
|
|
|
|
let stackView = UIStackView()
|
|
stackView.axis = .vertical
|
|
stackView.spacing = 8
|
|
stackView.distribution = .fillEqually
|
|
stackView.translatesAutoresizingMaskIntoConstraints = false
|
|
stackView.isLayoutMarginsRelativeArrangement = true
|
|
stackView.layoutMargins = UIEdgeInsets(top: 8, left: 0, bottom: 8, right: 0)
|
|
pageTurnDropdownView.addSubview(stackView)
|
|
|
|
let pageTurnOptions = ["Left to right", "Right to left", "Scroll"]
|
|
for title in pageTurnOptions {
|
|
let button = UIButton(type: .system)
|
|
button.setTitle(title, for: .normal)
|
|
button.setTitleColor(.white, for: .normal)
|
|
button.contentHorizontalAlignment = .left
|
|
button.backgroundColor = .clear
|
|
button.translatesAutoresizingMaskIntoConstraints = false
|
|
button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 0)
|
|
button.addTarget(
|
|
self, action: #selector(handlePageTurnOption), for: .touchDown
|
|
)
|
|
stackView.addArrangedSubview(button)
|
|
}
|
|
|
|
NSLayoutConstraint.activate([
|
|
pageTurnDropdownButton.leadingAnchor.constraint(
|
|
equalTo: backgroundColorDropdownButton.trailingAnchor, constant: 32
|
|
),
|
|
pageTurnDropdownButton.centerYAnchor.constraint(
|
|
equalTo: topBarView.centerYAnchor),
|
|
pageTurnDropdownButton.topAnchor.constraint(
|
|
equalTo: topBarView.topAnchor),
|
|
pageTurnDropdownButton.bottomAnchor.constraint(
|
|
equalTo: topBarView.bottomAnchor),
|
|
|
|
pageTurnDropdownView.topAnchor.constraint(
|
|
equalTo: pageTurnDropdownButton.bottomAnchor),
|
|
pageTurnDropdownView.leadingAnchor.constraint(
|
|
equalTo: pageTurnDropdownButton.leadingAnchor),
|
|
pageTurnDropdownView.trailingAnchor.constraint(
|
|
equalTo: pageTurnDropdownButton.trailingAnchor),
|
|
|
|
stackView.topAnchor.constraint(
|
|
equalTo: pageTurnDropdownView.topAnchor),
|
|
stackView.bottomAnchor.constraint(equalTo: pageTurnDropdownView.bottomAnchor),
|
|
stackView.leadingAnchor.constraint(equalTo: pageTurnDropdownView.leadingAnchor),
|
|
stackView.trailingAnchor.constraint(
|
|
equalTo: pageTurnDropdownView.trailingAnchor),
|
|
])
|
|
}
|
|
|
|
@objc func handlePageTurnOption(_ sender: UIButton) {
|
|
if let title = sender.currentTitle {
|
|
let prev = mode
|
|
switch title.lowercased() {
|
|
case "left to right": mode = .leftToRight
|
|
case "right to left": mode = .rightToLeft
|
|
case "scroll": mode = .scroll
|
|
default: break
|
|
}
|
|
if prev == mode { return }
|
|
if mode == .scroll {
|
|
setupScrolling()
|
|
} else {
|
|
imageLoader.loadImage(
|
|
at: getImagePath(
|
|
chapter: currentChapter,
|
|
volume: Int(metadata.chapters[currentChapter - 1].volume),
|
|
page: currentPage,
|
|
path: currentPath),
|
|
scaling: .scaleAspectFit
|
|
) {
|
|
[weak self] image in
|
|
self?.imageView.image = image
|
|
}
|
|
}
|
|
}
|
|
scrollingCollectionView.isHidden = mode != .scroll
|
|
leftView.isHidden = mode == .scroll
|
|
rightView.isHidden = mode == .scroll
|
|
togglePageTurnDropdown()
|
|
saveLocalState()
|
|
}
|
|
|
|
@objc func handleBackgroundColorOption(_ sender: UIButton) {
|
|
if let title = sender.currentTitle {
|
|
switch title.lowercased() {
|
|
case "white": readerView.backgroundColor = .white
|
|
case "gray": readerView.backgroundColor = .gray
|
|
case "black": readerView.backgroundColor = .black
|
|
case "red": readerView.backgroundColor = .red
|
|
case "blue": readerView.backgroundColor = .blue
|
|
default: break
|
|
}
|
|
}
|
|
toggleBackgroundColorDropdown()
|
|
saveLocalState()
|
|
}
|
|
|
|
@objc func togglePageTurnDropdown() {
|
|
pageTurnDropdownView.isHidden.toggle()
|
|
}
|
|
|
|
@objc func toggleBackgroundColorDropdown() {
|
|
backgroundColorDropdownView.isHidden.toggle()
|
|
}
|
|
|
|
// func gestureRecognizer(
|
|
// _ gestureRecognizer: UIGestureRecognizer,
|
|
// shouldRequireFailureOf otherGestureRecognizer: UIGestureRecognizer
|
|
// ) -> Bool {
|
|
// if gestureRecognizer == leftTap || gestureRecognizer == rightTap,
|
|
// otherGestureRecognizer == topTap
|
|
// {
|
|
// return true
|
|
// }
|
|
// return false
|
|
// }
|
|
|
|
func gestureRecognizer(
|
|
_ gestureRecognizer: UIGestureRecognizer,
|
|
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
|
|
) -> Bool {
|
|
return true
|
|
}
|
|
|
|
func toggleBar() {
|
|
topBarView.isHidden.toggle()
|
|
bottomBarView.isHidden.toggle()
|
|
|
|
if topBarView.isHidden {
|
|
pageTurnDropdownView.isHidden = true
|
|
backgroundColorDropdownView.isHidden = true
|
|
}
|
|
}
|
|
|
|
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
|
|
if scrollingCollectionView.isHidden { return }
|
|
updateInfo()
|
|
saveLocalState()
|
|
}
|
|
|
|
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
|
|
if scrollingCollectionView.isHidden { return }
|
|
if !decelerate {
|
|
updateInfo()
|
|
}
|
|
saveLocalState()
|
|
}
|
|
|
|
func hideBar() {
|
|
topBarView.isHidden = true
|
|
bottomBarView.isHidden = true
|
|
pageTurnDropdownView.isHidden = true
|
|
backgroundColorDropdownView.isHidden = true
|
|
}
|
|
|
|
@objc func handleTopTap() {
|
|
toggleBar()
|
|
}
|
|
|
|
@objc func handleLeftTap() {
|
|
switch mode {
|
|
case .rightToLeft: changeImage(turn: .next)
|
|
case .leftToRight: changeImage(turn: .previous)
|
|
case .scroll: break
|
|
}
|
|
}
|
|
|
|
@objc func handleRightTap() {
|
|
switch mode {
|
|
case .rightToLeft: changeImage(turn: .previous)
|
|
case .leftToRight: changeImage(turn: .next)
|
|
case .scroll: break
|
|
}
|
|
}
|
|
|
|
@objc func handleSwipe(_ gesture: UISwipeGestureRecognizer) {
|
|
switch gesture.direction {
|
|
case .left:
|
|
switch mode {
|
|
case .rightToLeft: changeImage(turn: .previous)
|
|
case .leftToRight: changeImage(turn: .next)
|
|
case .scroll: break
|
|
}
|
|
case .right:
|
|
switch mode {
|
|
case .rightToLeft: changeImage(turn: .next)
|
|
case .leftToRight: changeImage(turn: .previous)
|
|
case .scroll: break
|
|
}
|
|
default: break
|
|
}
|
|
}
|
|
|
|
func setupImageView() {
|
|
imageView.translatesAutoresizingMaskIntoConstraints = false
|
|
// Scaling is done when the image is loaded to avoid scaling on main thread
|
|
imageView.contentMode = .center
|
|
imageView.clipsToBounds = true
|
|
|
|
readerView.addSubview(imageView)
|
|
|
|
NSLayoutConstraint.activate([
|
|
imageView.topAnchor.constraint(equalTo: readerView.topAnchor),
|
|
imageView.bottomAnchor.constraint(equalTo: readerView.bottomAnchor),
|
|
imageView.leadingAnchor.constraint(equalTo: readerView.leadingAnchor),
|
|
imageView.trailingAnchor.constraint(equalTo: readerView.trailingAnchor),
|
|
])
|
|
}
|
|
|
|
func changeImage(turn: PageTurn) {
|
|
let scaling = UIView.ContentMode.scaleAspectFit
|
|
var (chapter, page) = getChapterAndPageFromTurn(
|
|
chapter: currentChapter, page: currentPage, turn: turn)
|
|
if (chapter, page) == (currentChapter, currentPage) { return }
|
|
var vol = Int(metadata.chapters[chapter - 1].volume)
|
|
if let path = getImagePath(chapter: chapter, volume: vol, page: page, path: currentPath) {
|
|
imageLoader.loadImage(at: path, scaling: scaling) {
|
|
[weak self] image in
|
|
self?.imageView.image = image
|
|
self?.updateInfo()
|
|
}
|
|
} else {
|
|
return
|
|
}
|
|
currentPage = page
|
|
currentChapter = chapter
|
|
|
|
for _ in 0...preloadCount {
|
|
(chapter, page) = getChapterAndPageFromTurn(
|
|
chapter: chapter, page: page, turn: .next)
|
|
vol = Int(metadata.chapters[chapter - 1].volume)
|
|
if let path = getImagePath(
|
|
chapter: chapter, volume: vol, page: page, path: currentPath)
|
|
{
|
|
imageLoader.preloadImage(at: path, scaling: scaling)
|
|
} else {
|
|
print("could not preload image")
|
|
}
|
|
}
|
|
|
|
saveLocalState()
|
|
}
|
|
|
|
func updateInfo() {
|
|
if metadata == nil { return }
|
|
if currentChapter == nil || currentPage == nil { return }
|
|
|
|
var text = "\(metadata.title)\n"
|
|
|
|
if let chapterTitle = metadata.chapters[currentChapter - 1].title {
|
|
text =
|
|
text + "\(chapterTitle)\n"
|
|
}
|
|
text =
|
|
text + """
|
|
Volume \(Int(metadata.chapters[currentChapter - 1].volume)) of \(Int(metadata.last_volume))
|
|
Chapter \(currentChapter!) of \(Int(metadata.last_chapter))
|
|
Page \(currentPage!) of \(metadata.chapters[currentChapter - 1].pages)
|
|
"""
|
|
if let size = imageLoader.size[
|
|
getImagePath(
|
|
chapter: currentChapter, volume: Int(metadata.chapters[currentChapter - 1].volume),
|
|
page: currentPage!, path: currentPath
|
|
).path]
|
|
{
|
|
text =
|
|
text + "\nImage size: \(Int(size.width))x\(Int(size.height))"
|
|
}
|
|
info.text = text
|
|
}
|
|
|
|
func getImagePath(chapter: Int, volume: Int, page: Int, path: URL) -> URL! {
|
|
let modernPath = path.appendingPathComponent(String(format: "volume_%04d", volume))
|
|
.appendingPathComponent(String(format: "chapter_%04d_page_%04d.png", chapter, page))
|
|
// if fileManager.fileExists(atPath: modernPath.path) {
|
|
return modernPath
|
|
// }
|
|
// return nil
|
|
}
|
|
|
|
func getChapterAndPageFromTurn(chapter: Int, page: Int, turn: PageTurn) -> (Int, Int) {
|
|
switch turn {
|
|
case .next:
|
|
if metadata.chapters.count >= chapter + 1 {
|
|
if page + 1 > metadata.chapters[chapter - 1].pages {
|
|
return (chapter + 1, 1)
|
|
} else {
|
|
return (chapter, page + 1)
|
|
}
|
|
} else {
|
|
return (chapter, min(metadata.chapters[chapter - 1].pages, page + 1))
|
|
}
|
|
case .previous:
|
|
if page < 2 {
|
|
if chapter > 1 {
|
|
return (
|
|
chapter - 1,
|
|
metadata.chapters[chapter - 2].pages
|
|
)
|
|
} else {
|
|
return (chapter, page)
|
|
}
|
|
} else {
|
|
return (chapter, max(1, page - 1))
|
|
}
|
|
}
|
|
}
|
|
|
|
func getMetadata(path: URL) -> Metadata? {
|
|
do {
|
|
let json = Data(
|
|
try String(contentsOfFile: path.appendingPathComponent("metadata.json").path)
|
|
.utf8)
|
|
let metadata = try JSONDecoder().decode(Metadata.self, from: json)
|
|
return metadata
|
|
|
|
} catch {
|
|
print("Error reading stuff")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func setImages(path: URL, metadata: Metadata) {
|
|
let scaling = UIView.ContentMode.scaleAspectFit
|
|
var directories: [URL] = []
|
|
if currentPage != nil && currentChapter != nil {
|
|
var vol = Int(metadata.chapters[currentChapter - 1].volume)
|
|
imageLoader.loadImage(
|
|
at: getImagePath(
|
|
chapter: currentChapter, volume: vol, page: currentPage, path: currentPath
|
|
),
|
|
scaling: scaling
|
|
) {
|
|
[weak self] image in
|
|
self?.imageView.image = image
|
|
self?.updateInfo()
|
|
}
|
|
|
|
var (chapter, page) = getChapterAndPageFromTurn(
|
|
chapter: currentChapter, page: currentPage, turn: .next)
|
|
for _ in 0...preloadCount {
|
|
vol = Int(metadata.chapters[chapter - 1].volume)
|
|
if let path = getImagePath(
|
|
chapter: chapter, volume: vol, page: page, path: currentPath)
|
|
{
|
|
imageLoader.preloadImage(at: path, scaling: scaling)
|
|
} else {
|
|
print("could not preload image")
|
|
}
|
|
(chapter, page) = getChapterAndPageFromTurn(
|
|
chapter: chapter, page: page, turn: .next)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
do {
|
|
let contents = try fileManager.contentsOfDirectory(
|
|
at: path,
|
|
includingPropertiesForKeys: [.isRegularFileKey],
|
|
options: [.skipsHiddenFiles]
|
|
)
|
|
|
|
directories = contents.filter { url in
|
|
var isDirectory: ObjCBool = false
|
|
return fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory)
|
|
&& isDirectory.boolValue
|
|
}.sorted { $0.path < $1.path }
|
|
let dir: URL = directories[0]
|
|
let ps: [String] = try fileManager.contentsOfDirectory(atPath: dir.path).sorted()
|
|
let current = dir.appendingPathComponent(ps[0])
|
|
imageView.image = UIImage(contentsOfFile: current.path)
|
|
|
|
let path_meta = current.lastPathComponent.components(separatedBy: "_")
|
|
assert(path_meta[0] == "chapter")
|
|
currentChapter = Int(path_meta[1])!
|
|
if path_meta[2] == "page" {
|
|
currentPage = Int(path_meta[3].components(separatedBy: ".")[0])
|
|
if currentPage == nil {
|
|
print("unable to set currentPage")
|
|
assert(false)
|
|
}
|
|
}
|
|
saveLocalState()
|
|
updateInfo()
|
|
} catch {
|
|
print("failed to set images")
|
|
}
|
|
}
|
|
|
|
func getPathFromComicName(name: String) -> URL? {
|
|
for comic in comics {
|
|
if comic.metadata.title == name {
|
|
return comic.path
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func getGlobalState() -> GlobalState {
|
|
let fileManager = FileManager.default
|
|
if let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first {
|
|
do {
|
|
let json = Data(
|
|
try String(
|
|
contentsOfFile: documentsURL.appendingPathComponent("state.json").path
|
|
).utf8)
|
|
return try JSONDecoder().decode(GlobalState.self, from: json)
|
|
} catch {
|
|
print("Error reading directory contents: \(error)")
|
|
}
|
|
}
|
|
return GlobalState(comicName: nil)
|
|
}
|
|
|
|
func getDocumentsURL() -> URL? {
|
|
if let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
|
|
.first
|
|
{
|
|
return documentsURL
|
|
}
|
|
print("failed to get documents dir")
|
|
return nil
|
|
}
|
|
|
|
extension ViewController: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
|
|
|
|
func collectionView(
|
|
_ collectionView: UICollectionView, numberOfItemsInSection section: Int
|
|
)
|
|
-> Int
|
|
{
|
|
if collectionView == comicCollectionView {
|
|
return comics.count
|
|
} else {
|
|
return pagesAvailable
|
|
}
|
|
}
|
|
|
|
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath)
|
|
-> UICollectionViewCell
|
|
{
|
|
if collectionView == comicCollectionView {
|
|
let cell =
|
|
collectionView.dequeueReusableCell(
|
|
withReuseIdentifier: "ComicImageCell", for: indexPath)
|
|
as! ComicImageCell
|
|
cell.imageView.image = comics[indexPath.item].cover
|
|
return cell
|
|
} else if collectionView == scrollingCollectionView {
|
|
let scaling = UIView.ContentMode.scaleAspectFill
|
|
let cell =
|
|
collectionView.dequeueReusableCell(
|
|
withReuseIdentifier: "ScrollingImageCell", for: indexPath)
|
|
as! ScrollingImageCell
|
|
if metadata == nil {
|
|
return cell
|
|
}
|
|
var (chapter, page) = chapterAndPages[indexPath.item]
|
|
if let url = getImagePath(
|
|
chapter: chapter,
|
|
volume: Int(metadata.chapters[chapter - 1].volume),
|
|
page: page, path: currentPath)
|
|
{
|
|
imageLoader.loadImage(at: url, scaling: scaling) { image in
|
|
cell.imageView.image = image
|
|
}
|
|
for _ in 0...preloadCount {
|
|
(chapter, page) = getChapterAndPageFromTurn(
|
|
chapter: chapter, page: page, turn: .next)
|
|
if let url = getImagePath(
|
|
chapter: chapter,
|
|
volume: Int(metadata.chapters[chapter - 1].volume),
|
|
page: page, path: currentPath)
|
|
{
|
|
imageLoader.preloadImage(at: url, scaling: scaling)
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return cell
|
|
} else {
|
|
assert(false)
|
|
}
|
|
|
|
// Xcode profiling sucks:
|
|
return
|
|
collectionView.dequeueReusableCell(
|
|
withReuseIdentifier: "ScrollingImageCell", for: indexPath)
|
|
as! ScrollingImageCell
|
|
}
|
|
|
|
func collectionView(
|
|
_ collectionView: UICollectionView,
|
|
layout collectionViewLayout: UICollectionViewLayout,
|
|
sizeForItemAt indexPath: IndexPath
|
|
) -> CGSize {
|
|
if collectionView == comicCollectionView {
|
|
let spacing: CGFloat = 8
|
|
let itemsPerRow: CGFloat = 3
|
|
let totalSpacing = (itemsPerRow - 1) * spacing
|
|
let width = (collectionView.bounds.width - totalSpacing) / itemsPerRow
|
|
return CGSize(width: width, height: width)
|
|
} else if collectionView == scrollingCollectionView {
|
|
if metadata == nil {
|
|
return CGSize()
|
|
}
|
|
if sizeList[indexPath.item] != CGSize(width: 0, height: 0) {
|
|
return CGSize(
|
|
width: readerView.bounds.width,
|
|
height: sizeList[indexPath.item].height * readerView.bounds.width
|
|
/ sizeList[indexPath.item].width)
|
|
}
|
|
let (chapter, page) = chapterAndPages[indexPath.item]
|
|
if let imagePath = getImagePath(
|
|
chapter: chapter,
|
|
volume: Int(metadata.chapters[chapter - 1].volume),
|
|
page: page, path: currentPath),
|
|
let imageSource = CGImageSourceCreateWithURL(imagePath as CFURL, nil),
|
|
let imageProperties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil)
|
|
as? [CFString: Any],
|
|
let height = imageProperties[kCGImagePropertyPixelHeight] as? CGFloat,
|
|
let width = imageProperties[kCGImagePropertyPixelWidth] as? CGFloat
|
|
{
|
|
sizeList[indexPath.item] = CGSize(width: width, height: height)
|
|
return CGSize(
|
|
width: readerView.bounds.width, height: height * readerView.bounds.width / width
|
|
)
|
|
}
|
|
return CGSize()
|
|
} else {
|
|
assert(false)
|
|
}
|
|
// Xcode profiling sucks:
|
|
return CGSize()
|
|
}
|
|
|
|
func collectionView(
|
|
_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath
|
|
) {
|
|
if collectionView == comicCollectionView {
|
|
let selectedComic = comics[indexPath.item]
|
|
|
|
readComic(name: selectedComic.metadata.title)
|
|
}
|
|
}
|
|
}
|
|
|
|
class ScrollingImageCell: UICollectionViewCell {
|
|
let imageView = UIImageView()
|
|
|
|
override init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
// Scaling is done when the image is loaded to avoid scaling on main thread
|
|
imageView.contentMode = .center
|
|
imageView.clipsToBounds = true
|
|
imageView.translatesAutoresizingMaskIntoConstraints = false
|
|
contentView.addSubview(imageView)
|
|
|
|
NSLayoutConstraint.activate([
|
|
imageView.topAnchor.constraint(equalTo: contentView.topAnchor),
|
|
imageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
|
|
imageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
|
imageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
|
|
])
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
}
|
|
|
|
class ComicImageCell: UICollectionViewCell {
|
|
let imageView = UIImageView()
|
|
|
|
override init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
imageView.contentMode = .scaleAspectFit
|
|
imageView.clipsToBounds = true
|
|
imageView.translatesAutoresizingMaskIntoConstraints = false
|
|
contentView.addSubview(imageView)
|
|
|
|
NSLayoutConstraint.activate([
|
|
imageView.topAnchor.constraint(equalTo: contentView.topAnchor),
|
|
imageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
|
|
imageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
|
imageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
|
|
])
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
}
|
|
|
|
class ImageLoader {
|
|
private let cache = NSCache<NSString, UIImage>()
|
|
private var loadingTasks: [String: [((UIImage?) -> Void)]] = [:]
|
|
var size: [String: CGSize] = [:]
|
|
|
|
init() {
|
|
// 128 MiB
|
|
cache.totalCostLimit = 128 * 1024 * 1024
|
|
}
|
|
|
|
func preloadImage(at path: URL, scaling: UIView.ContentMode) {
|
|
loadImage(at: path, scaling: scaling, completion: nil)
|
|
}
|
|
|
|
func loadImage(at path: URL, scaling: UIView.ContentMode, completion: ((UIImage?) -> Void)?) {
|
|
if let cached = cache.object(forKey: path.path as NSString) {
|
|
completion?(cached)
|
|
return
|
|
}
|
|
|
|
if loadingTasks[path.path] != nil {
|
|
if let completion = completion {
|
|
loadingTasks[path.path]?.append(completion)
|
|
}
|
|
return
|
|
}
|
|
|
|
loadingTasks[path.path] = completion != nil ? [completion!] : []
|
|
|
|
queue.async { [weak self] in
|
|
guard let self = self else { return }
|
|
guard var image = UIImage(contentsOfFile: path.path) else { return }
|
|
self.size[path.path] = image.size
|
|
let scaledSize = aspectSize(
|
|
for: image.size, in: UIScreen.main.bounds.size, scaling: scaling)
|
|
image = resizeImage(image, to: scaledSize)
|
|
guard let cost = imageByteSize(image) else { return }
|
|
self.cache.setObject(image, forKey: path.path as NSString, cost: cost)
|
|
|
|
DispatchQueue.main.async {
|
|
self.loadingTasks[path.path]?.forEach { $0(image) }
|
|
self.loadingTasks.removeValue(forKey: path.path)
|
|
}
|
|
}
|
|
}
|
|
|
|
func imageIfLoaded(at path: String) -> UIImage? {
|
|
return cache.object(forKey: path as NSString)
|
|
}
|
|
}
|
|
|
|
func imageByteSize(_ image: UIImage) -> Int? {
|
|
guard let cgImage = image.cgImage else { return nil }
|
|
return cgImage.bytesPerRow * cgImage.height
|
|
}
|
|
|
|
func resizeImage(_ image: UIImage, to size: CGSize) -> UIImage {
|
|
UIGraphicsBeginImageContextWithOptions(size, true, 0.0)
|
|
image.draw(in: CGRect(origin: .zero, size: size))
|
|
let scaledImage = UIGraphicsGetImageFromCurrentImageContext()
|
|
UIGraphicsEndImageContext()
|
|
return scaledImage!
|
|
}
|
|
|
|
func aspectSize(for imageSize: CGSize, in boundingSize: CGSize, scaling: UIView.ContentMode)
|
|
-> CGSize
|
|
{
|
|
let widthRatio = boundingSize.width / imageSize.width
|
|
let heightRatio = boundingSize.height / imageSize.height
|
|
let scale: CGFloat =
|
|
switch scaling {
|
|
case .scaleAspectFit: min(widthRatio, heightRatio)
|
|
case .scaleAspectFill: max(widthRatio, heightRatio)
|
|
default: 1
|
|
}
|
|
|
|
return CGSize(
|
|
width: imageSize.width * scale,
|
|
height: imageSize.height * scale
|
|
)
|
|
}
|