diff --git a/ImageViewer/ViewController.swift b/ImageViewer/ViewController.swift index 7effa98..b792d5c 100644 --- a/ImageViewer/ViewController.swift +++ b/ImageViewer/ViewController.swift @@ -1,7 +1,7 @@ //TODO: Implement support for bonus chapters -//TODO: Implement scrolling support //TODO: Implement Anilist support? -//TODO: Evict the oldest image from cache +//TODO: Properly avoid swallowing of input from UICollectionView used for scrolling +//TODO: Fix UICollectionView sizeForItemAt method performance either by caching or lazy loading import UIKit @@ -12,6 +12,12 @@ enum PageTurn { 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 @@ -29,9 +35,7 @@ struct GlobalState: Codable { } struct LocalState: Codable { - var chapter: Int - var page: Int - var turnMode: PageTurnMode = .leftToRight + var progress: ReadProgress var backgroundColor: String = "black" } @@ -53,7 +57,7 @@ struct Metadata: Decodable { struct ChapterMetadata: Decodable { var chapter: Float var volume: Float - var title: String + var title: String? var pages: Int } @@ -61,6 +65,11 @@ class ViewController: UIViewController, UIGestureRecognizerDelegate { var homeView = UIView() var readerView = UIView() + @IBOutlet var scrollingCollectionView: UICollectionView! + var scrollPos: CGPoint! + var hasSetContentOffset = false + var pageCount = 0 + var imageView = UIImageView() var mode = PageTurnMode.leftToRight var metadata: Metadata! @@ -71,6 +80,9 @@ class ViewController: UIViewController, UIGestureRecognizerDelegate { var leftTap: UITapGestureRecognizer! var rightTap: UITapGestureRecognizer! var topTap: UITapGestureRecognizer! + let leftView = UIView() + let rightView = UIView() + let topView = UIView() let topBarView = UIView() let bottomBarView = UIView() @@ -90,7 +102,7 @@ class ViewController: UIViewController, UIGestureRecognizerDelegate { let documentsURL = getDocumentsURL().unsafelyUnwrapped var comics: [Comic] = [] - var collectionView: UICollectionView! + @IBOutlet var comicCollectionView: UICollectionView! let imageLoader = ImageLoader.init() @@ -133,6 +145,7 @@ class ViewController: UIViewController, UIGestureRecognizerDelegate { ]) loadComics() setupImageView() + setupScrollingCollectionView() setupGestures() setupBar() setupHomeView() @@ -147,13 +160,14 @@ class ViewController: UIViewController, UIGestureRecognizerDelegate { layout.minimumInteritemSpacing = spacing layout.minimumLineSpacing = spacing - collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout) - collectionView.translatesAutoresizingMaskIntoConstraints = false - collectionView.dataSource = self - collectionView.delegate = self - collectionView.backgroundColor = .white - collectionView.register(ImageCell.self, forCellWithReuseIdentifier: "ImageCell") - homeView.addSubview(collectionView) + 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() { @@ -230,13 +244,20 @@ class ViewController: UIViewController, UIGestureRecognizerDelegate { 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.leftToRight(chapter: self.currentChapter, page: self.currentPage) + case .scroll: ReadProgress.scroll(scrollingCollectionView.contentOffset) + } localStateQueue.async { do { try JSONEncoder().encode( LocalState( - chapter: self.currentChapter, page: self.currentPage, turnMode: self.mode, - backgroundColor: self.convertColorToString( - color: color)) + progress: progress, backgroundColor: self.convertColorToString(color: color) + ) ).write( to: self.currentPath.appendingPathComponent("state.json")) } catch { @@ -252,9 +273,21 @@ class ViewController: UIViewController, UIGestureRecognizerDelegate { contentsOfFile: currentPath.appendingPathComponent("state.json").path ).utf8) let local = try JSONDecoder().decode(LocalState.self, from: json) - currentPage = local.page - currentChapter = local.chapter - mode = local.turnMode + 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 + } + self.mode = .scroll + } readerView.backgroundColor = convertStringToColor(str: local.backgroundColor) } catch { print("failed to load local state") @@ -273,6 +306,36 @@ class ViewController: UIViewController, UIGestureRecognizerDelegate { 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) { + var index = 0 + var (chapter, page) = (1, 1) + while index < indexPath.item { + (chapter, page) = getChapterAndPageFromTurn( + chapter: chapter, page: page, turn: .next) + index += 1 + } + 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 @@ -280,18 +343,50 @@ class ViewController: UIViewController, UIGestureRecognizerDelegate { if let path = getPathFromComicName(name: name) { currentPath = path metadata = getMetadata(path: path)! + pageCount = metadata.chapters.map { $0.pages }.reduce(0, +) globalState.comicName = metadata.title saveGlobalState() loadLocalState() - setImages(path: path, metadata: metadata) + if mode != .scroll { + scrollingCollectionView.isHidden = true + leftView.isHidden = false + rightView.isHidden = false + setImages(path: path, metadata: metadata) + } else { + leftView.isHidden = true + rightView.isHidden = true + scrollingCollectionView.isHidden = false + scrollingCollectionView.reloadData() + } } } - func setupTapZones() { - let leftView = UIView() - let rightView = UIView() - let topView = UIView() + func setupScrollingCollectionView() { + let layout = UICollectionViewFlowLayout() + layout.minimumInteritemSpacing = 0 + layout.minimumLineSpacing = 0 + scrollingCollectionView = UICollectionView( + frame: view.bounds, collectionViewLayout: layout) + scrollingCollectionView.translatesAutoresizingMaskIntoConstraints = false + scrollingCollectionView.isUserInteractionEnabled = true + 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 @@ -370,6 +465,10 @@ class ViewController: UIViewController, UIGestureRecognizerDelegate { 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), ]) @@ -543,10 +642,17 @@ class ViewController: UIViewController, UIGestureRecognizerDelegate { switch title.lowercased() { case "left to right": mode = .leftToRight case "right to left": mode = .rightToLeft - case "scroll": mode = .scroll + case "scroll": + if mode != .scroll { + scrollingCollectionView.reloadData() + } + mode = .scroll default: break } } + scrollingCollectionView.isHidden = mode != .scroll + leftView.isHidden = mode == .scroll + rightView.isHidden = mode == .scroll togglePageTurnDropdown() saveLocalState() } @@ -574,16 +680,23 @@ class ViewController: UIViewController, UIGestureRecognizerDelegate { 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, - shouldRequireFailureOf otherGestureRecognizer: UIGestureRecognizer + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer ) -> Bool { - if gestureRecognizer == leftTap || gestureRecognizer == rightTap, - otherGestureRecognizer == topTap - { - return true - } - return false + return true } func toggleBar() { @@ -596,6 +709,20 @@ class ViewController: UIViewController, UIGestureRecognizerDelegate { } } + 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 @@ -690,14 +817,26 @@ class ViewController: UIViewController, UIGestureRecognizerDelegate { } func updateInfo() { - info.text = """ - \(metadata.title) - \(metadata.chapters[currentChapter - 1].title) - 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) - Image size: \(Int(imageView.image!.size.width))x\(Int(imageView.image!.size.height)) - """ + 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)" + } + 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 image = imageView.image { + text = + text + "\nImage size: \(Int(image.size.width))x\(Int(image.size.height))" + } + info.text = text } func getImagePath(chapter: Int, volume: Int, page: Int, path: URL) -> URL! { @@ -706,21 +845,6 @@ class ViewController: UIViewController, UIGestureRecognizerDelegate { if fileManager.fileExists(atPath: modernPath.path) { return modernPath } - //TODO: Remove this - let newPath = path.appendingPathComponent(String(format: "volume_%04d", volume)) - .appendingPathComponent( - String(format: "chapter_%04d_image_%04d.png", chapter, page - 1)) - if fileManager.fileExists(atPath: newPath.path) { - return newPath - } - let alternatePath = path.appendingPathComponent(String(format: "volume_%04d", volume)) - .appendingPathComponent( - String(format: "chapter%03d_image_%03d.png", chapter, page - 1)) - if fileManager.fileExists(atPath: alternatePath.path) { - return alternatePath - } - // - print("Did not find image at path") return nil } @@ -822,12 +946,10 @@ class ViewController: UIViewController, UIGestureRecognizerDelegate { currentPage = Int(path_meta[3].components(separatedBy: ".")[0]) if currentPage == nil { print("unable to set currentPage") + assert(false) } - } else { - // TODO: Remove this - assert(path_meta[2] == "image") - currentPage = Int(path_meta[3].components(separatedBy: ".")[0])! + 1 } + saveLocalState() updateInfo() } catch { print("failed to set images") @@ -872,40 +994,139 @@ func getDocumentsURL() -> URL? { extension ViewController: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { - func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) + func collectionView( + _ collectionView: UICollectionView, numberOfItemsInSection section: Int + ) -> Int { - return comics.count + if collectionView == comicCollectionView { + return comics.count + } else { + return pageCount + } } func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - let cell = - collectionView.dequeueReusableCell(withReuseIdentifier: "ImageCell", for: indexPath) - as! ImageCell - cell.imageView.image = comics[indexPath.item].cover - return cell + 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 cell = + collectionView.dequeueReusableCell( + withReuseIdentifier: "ScrollingImageCell", for: indexPath) + as! ScrollingImageCell + if metadata == nil { + return cell + } + var index = 0 + var (chapter, page) = (1, 1) + while index < indexPath.item { + (chapter, page) = getChapterAndPageFromTurn( + chapter: chapter, page: page, turn: .next) + index += 1 + } + if let url = getImagePath( + chapter: chapter, + volume: Int(metadata.chapters[chapter - 1].volume), + page: page, path: currentPath) + { + // cell.imageView.image = UIImage(contentsOfFile: url.path) + imageLoader.loadImage(at: url) { image in cell.imageView.image = image } + } + return cell + } else { + assert(false) + } + + // Xcode profiling sucks: + return + collectionView.dequeueReusableCell( + withReuseIdentifier: "ScrollingImageCell", for: indexPath) + as! ScrollingImageCell } func collectionView( - _ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath ) -> CGSize { - 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) + 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() + } + var index = 0 + var (chapter, page) = (1, 1) + while index < indexPath.item { + (chapter, page) = getChapterAndPageFromTurn( + chapter: chapter, page: page, turn: .next) + index += 1 + } + 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 + { + return CGSize(width: readerView.bounds.width, height: height) + } + return CGSize(width: readerView.bounds.width, height: readerView.bounds.height) + } else { + assert(false) + } + // Xcode profiling sucks: + return CGSize() } - func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - let selectedComic = comics[indexPath.item] - readComic(name: selectedComic.metadata.title) + func collectionView( + _ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath + ) { + if collectionView == comicCollectionView { + let selectedComic = comics[indexPath.item] + + readComic(name: selectedComic.metadata.title) + } } } -class ImageCell: UICollectionViewCell { +class ScrollingImageCell: UICollectionViewCell { + let imageView = UIImageView() + + override init(frame: CGRect) { + super.init(frame: frame) + imageView.contentMode = .scaleAspectFill + 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) { diff --git a/run.sh b/run.sh index 2ac8c71..8cdef54 100755 --- a/run.sh +++ b/run.sh @@ -18,7 +18,7 @@ xcodebuild -scheme "$SCHEME" -configuration "$BUILD_TYPE" -derivedDataPath "$DER APP_PATH="$DERIVED_DATA/Build/Products/Debug-iphonesimulator/$APP_NAME.app" xcrun simctl install booted "$APP_PATH" -xcrun simctl spawn booted log stream --predicate 'process == "ImageViewer"' --style syslog & +# xcrun simctl spawn booted log stream --predicate 'process == "ImageViewer"' --style syslog & xcrun simctl launch booted "$COMPANY.$APP_NAME" sudo sed -i '' '/developerservices2\.apple\.com/d' /etc/hosts