├── .gitignore ├── .travis.yml ├── LICENSE ├── Podfile ├── Podfile.lock ├── README.md ├── V2EX.xcodeproj ├── project.pbxproj └── xcshareddata │ └── xcschemes │ └── V2EX.xcscheme └── V2EX ├── App ├── AppDelegate.swift ├── AppSetting.swift ├── AppStyle.swift └── Theme.swift ├── Cells ├── FavoriteNodeViewCell.swift ├── LoadMoreCommentCell.swift ├── MessageViewCell.swift ├── NodeListViewCell.swift ├── NodeNavigationViewCell.swift ├── NodeTopicsViewCell.swift ├── ProfileMenuViewCell.swift ├── TimelineReplyViewCell.swift ├── TimelineTopicViewCell.swift ├── TopicDetailsCommentCell.swift └── TopicViewCell.swift ├── Extensions ├── Error.swift ├── InfiniteScrollView.swift ├── NSLayoutManager.swift ├── ReusableView.swift ├── String.swift ├── UIImage.swift └── UITableViewController.swift ├── Models ├── Account.swift ├── AllPostsSection.swift ├── Comment.swift ├── FavoriteItem.swift ├── Message.swift ├── Node.swift ├── Privacy.swift ├── Reply.swift ├── TapLink.swift ├── TimelineSection.swift ├── Topic.swift ├── TopicDetailsSection.swift ├── TopicListSection.swift └── User.swift ├── Networking └── API.swift ├── Services ├── ActivityIndicator.swift └── HTMLParser.swift ├── Supporting Files ├── ABOUT.html ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ ├── Icon-App-20x20@1x.png │ │ ├── Icon-App-20x20@2x-1.png │ │ ├── Icon-App-20x20@2x.png │ │ ├── Icon-App-20x20@3x.png │ │ ├── Icon-App-29x29@1x.png │ │ ├── Icon-App-29x29@2x-1.png │ │ ├── Icon-App-29x29@2x.png │ │ ├── Icon-App-29x29@3x.png │ │ ├── Icon-App-40x40@1x.png │ │ ├── Icon-App-40x40@2x-1.png │ │ ├── Icon-App-40x40@2x.png │ │ ├── Icon-App-40x40@3x.png │ │ ├── Icon-App-60x60@2x.png │ │ ├── Icon-App-60x60@3x.png │ │ ├── Icon-App-76x76@1x.png │ │ ├── Icon-App-76x76@2x.png │ │ ├── Icon-App-83.5x83.5@2x.png │ │ └── itunes.png │ ├── Contents.json │ └── Images │ │ ├── Contents.json │ │ ├── avatar_default.imageset │ │ ├── Contents.json │ │ └── avatar_default.pdf │ │ ├── cancel_X.imageset │ │ ├── Contents.json │ │ ├── cancel_X@2x.png │ │ └── cancel_X@3x.png │ │ ├── google_icon.imageset │ │ ├── Contents.json │ │ ├── google_icon@2x.png │ │ └── google_icon@3x.png │ │ ├── hud_progress.imageset │ │ ├── Contents.json │ │ └── hud_progress.pdf │ │ ├── line.imageset │ │ ├── Contents.json │ │ └── line.pdf │ │ ├── nav_back.imageset │ │ ├── Contents.json │ │ ├── nav_back@2x.png │ │ └── nav_back@3x.png │ │ ├── nav_drawer.imageset │ │ ├── Contents.json │ │ ├── nav_drawer@2x.png │ │ └── nav_drawer@3x.png │ │ ├── nav_more.imageset │ │ ├── Contents.json │ │ ├── nav_more@2x.png │ │ └── nav_more@3x.png │ │ ├── share_thumbnail.imageset │ │ ├── Contents.json │ │ ├── share_thumbnail@2x.png │ │ └── share_thumbnail@3x.png │ │ ├── slide_menu_favorite.imageset │ │ ├── Contents.json │ │ ├── slide_menu_favorite@2x.png │ │ └── slide_menu_favorite@3x.png │ │ ├── slide_menu_message.imageset │ │ ├── Contents.json │ │ ├── slide_menu_message@2x.png │ │ └── slide_menu_message@3x.png │ │ ├── slide_menu_setting.imageset │ │ ├── Contents.json │ │ ├── slide_menu_setting@2x.png │ │ └── slide_menu_setting@3x.png │ │ └── slide_menu_topic.imageset │ │ ├── Contents.json │ │ ├── slide_menu_topic@2x.png │ │ └── slide_menu_topic@3x.png ├── Home.storyboard ├── Info.plist ├── LICENSES.html ├── LaunchScreen.storyboard ├── Main.storyboard ├── Timeline.storyboard └── style.css ├── ViewControllers ├── AboutLicensesViewController.swift ├── AllPostsViewController.swift ├── CreateTopicViewController.swift ├── DrawerViewController.swift ├── FavoriteViewController.swift ├── HomeViewController.swift ├── LoginViewController.swift ├── MessageViewController.swift ├── NodeNavigationViewController.swift ├── NodeTopicsViewController.swift ├── NodesViewController.swift ├── ProfileViewController.swift ├── SettingViewController.swift ├── TimelineViewController.swift └── TopicDetailsViewController.swift ├── ViewModel ├── AllPostsViewModel.swift ├── FavoriteViewModel.swift ├── HomeViewModel.swift ├── LoginViewModel.swift ├── MessageViewModel.swift ├── NodeTopicsViewModel.swift ├── SettingViewModel.swift ├── TimelineViewModel.swift └── TopicDetailsViewModel.swift └── Views ├── GrowingTextView.swift ├── HUD.swift ├── InputCommentBar.swift ├── LoginButton.swift ├── PlaceHolderTextView.swift ├── ProfileHeaderView.swift ├── TimelineHeaderView.swift └── TopicDetailsHeaderView.swift /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | .DS_Store 5 | ## Build generated 6 | build/ 7 | DerivedData/ 8 | 9 | ## Various settings 10 | *.pbxuser 11 | !default.pbxuser 12 | *.mode1v3 13 | !default.mode1v3 14 | *.mode2v3 15 | !default.mode2v3 16 | *.perspectivev3 17 | !default.perspectivev3 18 | xcuserdata 19 | 20 | ## Other 21 | *.moved-aside 22 | *.xccheckout 23 | *.xcworkspace 24 | !default.xcworkspace 25 | 26 | ## Other 27 | *.xccheckout 28 | *.moved-aside 29 | *.xcuserstate 30 | *.xcscmblueprint 31 | 32 | ## Obj-C/Swift specific 33 | *.hmap 34 | *.ipa 35 | *.dSYM.zip 36 | *.dSYM 37 | 38 | ## Playgrounds 39 | timeline.xctimeline 40 | playground.xcworkspace 41 | 42 | # Swift Package Manager 43 | # 44 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 45 | # Packages/ 46 | # Package.pins 47 | .build/ 48 | 49 | # CocoaPods 50 | # 51 | # We recommend against adding the Pods directory to your .gitignore. However 52 | # you should judge for yourself, the pros and cons are mentioned at: 53 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 54 | # 55 | Pods/ 56 | 57 | # Carthage 58 | # 59 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 60 | # Carthage/Checkouts 61 | 62 | Carthage/Build 63 | 64 | # fastlane 65 | # 66 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 67 | # screenshots whenever they are needed. 68 | # For more information about the recommended setup visit: 69 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 70 | 71 | fastlane/report.xml 72 | fastlane/Preview.html 73 | fastlane/screenshots 74 | fastlane/test_output -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | 2 | language: objective-c 3 | os: osx 4 | osx_image: xcode9 5 | before_install: 6 | - gem install cocoapods --pre --no-rdoc --no-ri --no-document --quiet 7 | - pod repo update 8 | script: 9 | - export LC_CTYPE=en_US.UTF-8 10 | - set -o pipefail 11 | - xcodebuild -version 12 | - xcodebuild -showsdks 13 | - xcodebuild clean -workspace V2EX.xcworkspace -scheme V2EX | xcpretty 14 | - xcodebuild -workspace V2EX.xcworkspace -scheme V2EX -destination 'platform=iOS Simulator,name=iPhone 7,OS=11.0' | xcpretty 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 darker 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Podfile: -------------------------------------------------------------------------------- 1 | platform :ios, '10.0' 2 | use_frameworks! 3 | inhibit_all_warnings! 4 | target 'V2EX' do 5 | pod 'Kanna', '~> 4.0.3' 6 | pod 'RxCocoa', '~> 4.4.2' 7 | pod 'RxDataSources', '~> 3.1.0' 8 | pod 'Moya/RxSwift', '~> 12.0.1' 9 | pod 'Kingfisher', '~> 5.3.0' 10 | pod 'PKHUD', '~> 5.2.1' 11 | pod 'SKPhotoBrowser', '~> 6.0.0' 12 | pod '1PasswordExtension', '~> 1.8.5' 13 | pod 'MonkeyKing', '~> 1.13.0' 14 | end 15 | 16 | post_install do |installer| 17 | installer.pods_project.targets.each do |target| 18 | target.build_configurations.each do |config| 19 | config.build_settings['SWIFT_VERSION'] = '4.2' 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /Podfile.lock: -------------------------------------------------------------------------------- 1 | PODS: 2 | - 1PasswordExtension (1.8.5) 3 | - Alamofire (4.8.1) 4 | - Differentiator (3.1.0) 5 | - Kanna (4.0.3) 6 | - Kingfisher (5.3.0) 7 | - MonkeyKing (1.13.0) 8 | - Moya/Core (12.0.1): 9 | - Alamofire (~> 4.1) 10 | - Result (~> 4.0) 11 | - Moya/RxSwift (12.0.1): 12 | - Moya/Core 13 | - RxSwift (~> 4.0) 14 | - PKHUD (5.2.1) 15 | - Result (4.1.0) 16 | - RxAtomic (4.4.2) 17 | - RxCocoa (4.4.2): 18 | - RxSwift (>= 4.4.2, ~> 4.4) 19 | - RxDataSources (3.1.0): 20 | - Differentiator (~> 3.0) 21 | - RxCocoa (~> 4.0) 22 | - RxSwift (~> 4.0) 23 | - RxSwift (4.4.2): 24 | - RxAtomic (>= 4.4.2, ~> 4.4) 25 | - SKPhotoBrowser (6.0.0) 26 | 27 | DEPENDENCIES: 28 | - 1PasswordExtension (~> 1.8.5) 29 | - Kanna (~> 4.0.3) 30 | - Kingfisher (~> 5.3.0) 31 | - MonkeyKing (~> 1.13.0) 32 | - Moya/RxSwift (~> 12.0.1) 33 | - PKHUD (~> 5.2.1) 34 | - RxCocoa (~> 4.4.2) 35 | - RxDataSources (~> 3.1.0) 36 | - SKPhotoBrowser (~> 6.0.0) 37 | 38 | SPEC REPOS: 39 | https://github.com/cocoapods/specs.git: 40 | - 1PasswordExtension 41 | - Alamofire 42 | - Differentiator 43 | - Kanna 44 | - Kingfisher 45 | - MonkeyKing 46 | - Moya 47 | - PKHUD 48 | - Result 49 | - RxAtomic 50 | - RxCocoa 51 | - RxDataSources 52 | - RxSwift 53 | - SKPhotoBrowser 54 | 55 | SPEC CHECKSUMS: 56 | 1PasswordExtension: 0e95bdea64ec8ff2f4f693be5467a09fac42a83d 57 | Alamofire: 16ce2c353fb72865124ddae8a57c5942388f4f11 58 | Differentiator: be49ca3408f0ecfc761e4c7763d20c62be01b9ad 59 | Kanna: 0ebbdd0e7e3308f0aafbd569e79c8cadc5eb877c 60 | Kingfisher: 4692c783ffb99e9e2e40a6eb3e518c3b96c6fa8c 61 | MonkeyKing: cdaa3e93d4802464eceea619aba87f70c31db2e1 62 | Moya: cf730b3cd9e005401ef37a85143aa141a12fd38f 63 | PKHUD: 4e6162c4c39ac367c6934e9855c6b4762ee16256 64 | Result: bd966fac789cc6c1563440b348ab2598cc24d5c7 65 | RxAtomic: d00e97c10db88c6f08540e0bf2752fc5a2404167 66 | RxCocoa: 477990dc3b4c3ff55fb0ac77e1cc06244e0aaec8 67 | RxDataSources: a843bad90c29817f5923ec8163f4af2de084ceb3 68 | RxSwift: 74c29b693c8e42b0f64400e8b06564575742d649 69 | SKPhotoBrowser: 76eb862685761707b5745fde7762757966565e07 70 | 71 | PODFILE CHECKSUM: dbd3d12fb911bc1564dae9743dbb0a8230fbf649 72 | 73 | COCOAPODS: 1.5.3 74 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/darkerk/v2ex.svg?branch=master)](https://travis-ci.org/darkerk/v2ex) 2 | ![Swift](http://img.shields.io/badge/swift-4-brightgreen.svg) 3 | 4 | ## v2ex 5 | 6 | The unofficial V2EX app for iOS 7 | 8 |    9 | 10 | 11 | ## Requirements 12 | 13 | - iOS 10.0+ 14 | - Xcode 9.2 15 | - Swift 4.1 16 | 17 | ### Contact 18 | 19 | Follow and contact me with [email](mailto:appwgh@gmail.com). If you find an issue, just [open a ticket](https://github.com/darkerk/v2ex/issues/new). Pull requests are warmly welcome as well. 20 | 21 | ## License 22 | 23 | Copyright (c) 2017 darker 24 | 25 | Permission is hereby granted, free of charge, to any person obtaining a copy 26 | of this software and associated documentation files (the "Software"), to deal 27 | in the Software without restriction, including without limitation the rights 28 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 29 | copies of the Software, and to permit persons to whom the Software is 30 | furnished to do so, subject to the following conditions: 31 | 32 | The above copyright notice and this permission notice shall be included in all 33 | copies or substantial portions of the Software. 34 | 35 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 36 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 37 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 38 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 39 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 40 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 41 | SOFTWARE. 42 | 43 | -------------------------------------------------------------------------------- /V2EX.xcodeproj/xcshareddata/xcschemes/V2EX.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 39 | 40 | 41 | 42 | 43 | 44 | 54 | 56 | 62 | 63 | 64 | 65 | 69 | 70 | 71 | 72 | 73 | 74 | 80 | 82 | 88 | 89 | 90 | 91 | 93 | 94 | 97 | 98 | 99 | -------------------------------------------------------------------------------- /V2EX/App/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // V2EX 4 | // 5 | // Created by darker on 2017/2/23. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import MonkeyKing 11 | 12 | @UIApplicationMain 13 | class AppDelegate: UIResponder, UIApplicationDelegate { 14 | 15 | var window: UIWindow? 16 | 17 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 18 | // Override point for customization after application launch. 19 | AppStyle.shared.setupBarStyle() 20 | MonkeyKing.registerAccount(.weChat(appID: "wx9e26f0dc06b3f030", appKey: nil, miniAppID: nil)) 21 | return true 22 | } 23 | 24 | func applicationWillResignActive(_ application: UIApplication) { 25 | // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. 26 | // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game. 27 | } 28 | 29 | func applicationDidEnterBackground(_ application: UIApplication) { 30 | // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. 31 | // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. 32 | } 33 | 34 | func applicationWillEnterForeground(_ application: UIApplication) { 35 | // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background. 36 | } 37 | 38 | func applicationDidBecomeActive(_ application: UIApplication) { 39 | // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. 40 | } 41 | 42 | func applicationWillTerminate(_ application: UIApplication) { 43 | // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. 44 | } 45 | 46 | 47 | func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool { 48 | return MonkeyKing.handleOpenURL(url) 49 | 50 | // return GIDSignIn.sharedInstance().handle(url, sourceApplication: options[UIApplicationOpenURLOptionsKey.sourceApplication] as? String, annotation: options[UIApplicationOpenURLOptionsKey.annotation]) 51 | } 52 | 53 | } 54 | 55 | /** 56 | extension AppDelegate: GIDSignInDelegate { 57 | func sign(_ signIn: GIDSignIn!, didSignInFor user: GIDGoogleUser!, withError error: Error!) { 58 | print(user) 59 | } 60 | 61 | func sign(_ signIn: GIDSignIn!, didDisconnectWith user: GIDGoogleUser!, withError error: Error!) { 62 | 63 | } 64 | } 65 | **/ 66 | -------------------------------------------------------------------------------- /V2EX/App/AppSetting.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppSetting.swift 3 | // V2EX 4 | // 5 | // Created by darker on 2017/3/21. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import AVFoundation 11 | import Photos 12 | import SafariServices 13 | import SKPhotoBrowser 14 | 15 | struct AppSetting { 16 | static var isCameraEnabled: Bool { 17 | let status = AVCaptureDevice.authorizationStatus(for: AVMediaType.video) 18 | return status != .restricted && status != .denied 19 | } 20 | 21 | static var isAlbumEnabled: Bool { 22 | let status = PHPhotoLibrary.authorizationStatus() 23 | return status != .restricted && status != .denied 24 | } 25 | 26 | static func openWebBrowser(from viewController: UIViewController, URL: URL) { 27 | let browser = SFSafariViewController(url: URL) 28 | viewController.present(browser, animated: true, completion: nil) 29 | } 30 | 31 | static func openPhotoBrowser(from viewController: UIViewController, src: String) { 32 | let photo = SKPhoto.photoWithImageURL(src) 33 | photo.shouldCachePhotoURLImage = true 34 | 35 | let browser = SKPhotoBrowser(photos: [photo]) 36 | browser.initializePageIndex(0) 37 | viewController.present(browser, animated: true, completion: nil) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /V2EX/App/AppStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppStyle.swift 3 | // V2EX 4 | // 5 | // Created by darker on 2017/3/9. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import RxSwift 11 | 12 | let nightOnKey = "theme.night.on" 13 | 14 | struct CSSColorMark { 15 | static let background = "#background#" 16 | static let subtleBackground = "#subtle#" 17 | static let topicContent = "#topic_content#" 18 | static let replyContent = "#reply_content#" 19 | static let hyperlink = "#hyperlink#" 20 | static let codePre = "#codePre#" 21 | static let separator = "#separator#" 22 | } 23 | 24 | struct AppStyle { 25 | static var shared = AppStyle() 26 | 27 | let themeUpdateVariable = Variable(false) 28 | 29 | var css: String = "" 30 | var theme: Theme = UserDefaults.standard.bool(forKey: nightOnKey) ? .night : .normal { 31 | didSet { 32 | UserDefaults.standard.set(theme == .night, forKey: nightOnKey) 33 | themeUpdateVariable.value = true 34 | } 35 | } 36 | 37 | private init() { 38 | if let stylePath = Bundle.main.path(forResource: "style", ofType: "css") { 39 | do { 40 | self.css = try String(contentsOfFile: stylePath, encoding: .utf8) 41 | } catch { 42 | 43 | } 44 | } 45 | } 46 | 47 | func setupBarStyle(_ navigationBar: UINavigationBar = UINavigationBar.appearance()) { 48 | navigationBar.isTranslucent = false 49 | navigationBar.tintColor = theme.tintColor 50 | navigationBar.barTintColor = theme.barTintColor 51 | navigationBar.titleTextAttributes = [NSAttributedString.Key.foregroundColor: theme.navigationBarTitleColor, NSAttributedString.Key.font: UIFont.systemFont(ofSize: 17)] 52 | navigationBar.backIndicatorImage = #imageLiteral(resourceName: "nav_back") 53 | navigationBar.backIndicatorTransitionMaskImage = #imageLiteral(resourceName: "nav_back") 54 | UIBarButtonItem.appearance().setTitleTextAttributes([NSAttributedString.Key.font: UIFont.systemFont(ofSize: 16)], for: .normal) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /V2EX/App/Theme.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Theme.swift 3 | // V2EX 4 | // 5 | // Created by wgh on 2017/4/26. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | enum Theme { 12 | case normal 13 | case night 14 | } 15 | 16 | extension Theme { 17 | var activityIndicatorStyle: UIActivityIndicatorView.Style { 18 | switch self { 19 | case .normal: 20 | return .gray 21 | case .night: 22 | return .white 23 | } 24 | } 25 | 26 | var tableBackgroundColor: UIColor { 27 | switch self { 28 | case .normal: 29 | return UIColor.white 30 | case .night: 31 | return #colorLiteral(red: 0.07843137255, green: 0.1137254902, blue: 0.1490196078, alpha: 1) 32 | } 33 | } 34 | 35 | var tableGroupBackgroundColor: UIColor { 36 | switch self { 37 | case .normal: 38 | return #colorLiteral(red: 0.937254902, green: 0.937254902, blue: 0.9568627451, alpha: 1) 39 | case .night: 40 | return #colorLiteral(red: 0.07843137255, green: 0.1137254902, blue: 0.1490196078, alpha: 1) 41 | } 42 | } 43 | 44 | var tableHeaderBackgroundColor: UIColor { 45 | switch self { 46 | case .normal: 47 | return UIColor(red:0.97, green:0.97, blue:0.97, alpha:1.00) 48 | case .night: 49 | return #colorLiteral(red: 0.07843137255, green: 0.1137254902, blue: 0.1490196078, alpha: 1) 50 | } 51 | } 52 | 53 | var cellBackgroundColor: UIColor { 54 | switch self { 55 | case .normal: 56 | return UIColor.white 57 | case .night: 58 | return #colorLiteral(red: 0.1019607843, green: 0.1568627451, blue: 0.2117647059, alpha: 1) 59 | } 60 | } 61 | 62 | var cellSubBackgroundColor: UIColor { 63 | switch self { 64 | case .normal: 65 | return #colorLiteral(red: 0.9607843137, green: 0.9607843137, blue: 0.9607843137, alpha: 1) 66 | case .night: 67 | return #colorLiteral(red: 0.1411764706, green: 0.2039215686, blue: 0.2784313725, alpha: 1) 68 | } 69 | } 70 | 71 | var cellSelectedBackgroundColor: UIColor { 72 | switch self { 73 | case .normal: 74 | return UIColor(red: 0.901961, green: 0.901961, blue: 0.901961, alpha: 1) 75 | case .night: 76 | return #colorLiteral(red: 0.1411764706, green: 0.2039215686, blue: 0.2784313725, alpha: 1) 77 | } 78 | } 79 | 80 | var separatorColor: UIColor { 81 | switch self { 82 | case .normal: 83 | return UIColor(red: 0.901961, green: 0.901961, blue: 0.901961, alpha: 1) 84 | case .night: 85 | return UIColor.black 86 | } 87 | } 88 | 89 | var hyperlinkColor: UIColor { 90 | switch self { 91 | case .normal: 92 | return #colorLiteral(red: 0.4666666667, green: 0.5019607843, blue: 0.5294117647, alpha: 1) 93 | case .night: 94 | return #colorLiteral(red: 0.1137254902, green: 0.631372549, blue: 0.9490196078, alpha: 1) 95 | } 96 | } 97 | 98 | /// webView 99 | var webBackgroundColorHex: String { 100 | switch self { 101 | case .normal: 102 | return "#ffffff" 103 | case .night: 104 | return "#1A2836" 105 | } 106 | } 107 | 108 | var webSubBackgroundColorHex: String { 109 | switch self { 110 | case .normal: 111 | return "#fffff9" 112 | case .night: 113 | return "#243447" 114 | } 115 | } 116 | 117 | var webCodePreColorHex: String { 118 | switch self { 119 | case .normal: 120 | return "#f8f8f8" 121 | case .night: 122 | return "#243447" 123 | } 124 | } 125 | 126 | var webTopicTextColorHex: String { 127 | switch self { 128 | case .normal: 129 | return "#646464" 130 | case .night: 131 | return "#9BAFCC" 132 | } 133 | } 134 | 135 | var webLinkColorHex: String { 136 | switch self { 137 | case .normal: 138 | return "#778087" 139 | case .night: 140 | return "#1DA1F2" 141 | } 142 | } 143 | 144 | var webLineColorHex: String { 145 | switch self { 146 | case .normal: 147 | return "#e2e2e2" 148 | case .night: 149 | return "#243447" 150 | } 151 | } 152 | 153 | /// 白天模式RGB(64, 64, 64) 154 | var black64Color: UIColor { 155 | switch self { 156 | case .normal: 157 | return #colorLiteral(red: 0.2509803922, green: 0.2509803922, blue: 0.2509803922, alpha: 1) 158 | case .night: 159 | return #colorLiteral(red: 0.6078431373, green: 0.6862745098, blue: 0.8, alpha: 1) 160 | } 161 | } 162 | 163 | /// 白天模式RGB(102, 102, 102) 164 | var black102Color: UIColor { 165 | switch self { 166 | case .normal: 167 | return #colorLiteral(red: 0.4, green: 0.4, blue: 0.4, alpha: 1) 168 | case .night: 169 | return #colorLiteral(red: 0.6078431373, green: 0.6862745098, blue: 0.8, alpha: 1) 170 | } 171 | } 172 | 173 | /// 白天模式RGB(153, 153, 153) 174 | var black153Color: UIColor { 175 | switch self { 176 | case .normal: 177 | return #colorLiteral(red: 0.6, green: 0.6, blue: 0.6, alpha: 1) 178 | case .night: 179 | return #colorLiteral(red: 0.4588235294, green: 0.5137254902, blue: 0.6, alpha: 1) 180 | } 181 | } 182 | 183 | /// navigationBar 184 | var tintColor: UIColor { 185 | switch self { 186 | case .normal: 187 | return #colorLiteral(red: 0.2509803922, green: 0.2509803922, blue: 0.2509803922, alpha: 1) 188 | case .night: 189 | return #colorLiteral(red: 0.1137254902, green: 0.631372549, blue: 0.9490196078, alpha: 1) 190 | } 191 | } 192 | 193 | var barTintColor: UIColor { 194 | switch self { 195 | case .normal: 196 | return UIColor.white 197 | case .night: 198 | return #colorLiteral(red: 0.1411764706, green: 0.2039215686, blue: 0.2784313725, alpha: 1) 199 | } 200 | } 201 | 202 | var navigationBarTitleColor: UIColor { 203 | switch self { 204 | case .normal: 205 | return #colorLiteral(red: 0.2509803922, green: 0.2509803922, blue: 0.2509803922, alpha: 1) 206 | case .night: 207 | return #colorLiteral(red: 0.6078431373, green: 0.6862745098, blue: 0.8, alpha: 1) 208 | } 209 | } 210 | 211 | /// 话题列表 212 | var topicCellNodeBackgroundColor: UIColor { 213 | switch self { 214 | case .normal: 215 | return #colorLiteral(red: 0.9607843137, green: 0.9607843137, blue: 0.9607843137, alpha: 1) 216 | case .night: 217 | return #colorLiteral(red: 0.1411764706, green: 0.2039215686, blue: 0.2784313725, alpha: 1) 218 | } 219 | } 220 | 221 | var topicReplyCountBackgroundColor: UIColor { 222 | switch self { 223 | case .normal: 224 | return #colorLiteral(red: 0.6666666667, green: 0.6901960784, blue: 0.7764705882, alpha: 1) 225 | case .night: 226 | return #colorLiteral(red: 0.1411764706, green: 0.2039215686, blue: 0.2784313725, alpha: 1) 227 | } 228 | } 229 | 230 | var topicReplyCountTextColor: UIColor { 231 | switch self { 232 | case .normal: 233 | return UIColor.white 234 | case .night: 235 | return #colorLiteral(red: 0.6078431373, green: 0.6862745098, blue: 0.8, alpha: 1) 236 | } 237 | } 238 | 239 | var textPlaceHolderColor: UIColor { 240 | switch self { 241 | case .normal: 242 | return UIColor(white: 0.8, alpha: 1.0) 243 | case .night: 244 | return #colorLiteral(red: 0.4196078431, green: 0.4901960784, blue: 0.5490196078, alpha: 1) 245 | } 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /V2EX/Cells/FavoriteNodeViewCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FavoriteNodeViewCell.swift 3 | // V2EX 4 | // 5 | // Created by darker on 2017/3/20. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Kingfisher 11 | 12 | class FavoriteNodeViewCell: UITableViewCell { 13 | 14 | @IBOutlet weak var iconView: UIImageView! 15 | @IBOutlet weak var nameLabel: UILabel! 16 | @IBOutlet weak var countLabel: UILabel! 17 | 18 | var node: Node? { 19 | willSet { 20 | if let model = newValue { 21 | iconView.kf.setImage(with: URL(string: model.iconURLString), placeholder: #imageLiteral(resourceName: "slide_menu_setting")) 22 | nameLabel.text = model.name 23 | countLabel.text = " \(model.comments) " 24 | countLabel.isHidden = model.comments == 0 25 | } 26 | } 27 | } 28 | 29 | override func awakeFromNib() { 30 | super.awakeFromNib() 31 | // Initialization code 32 | self.backgroundColor = AppStyle.shared.theme.cellBackgroundColor 33 | contentView.backgroundColor = AppStyle.shared.theme.cellBackgroundColor 34 | let selectedView = UIView() 35 | selectedView.backgroundColor = AppStyle.shared.theme.cellSelectedBackgroundColor 36 | self.selectedBackgroundView = selectedView 37 | 38 | nameLabel.textColor = AppStyle.shared.theme.black64Color 39 | 40 | countLabel.clipsToBounds = true 41 | countLabel.layer.cornerRadius = 9 42 | countLabel.backgroundColor = AppStyle.shared.theme.topicReplyCountBackgroundColor 43 | countLabel.textColor = AppStyle.shared.theme.topicReplyCountTextColor 44 | } 45 | 46 | override func setSelected(_ selected: Bool, animated: Bool) { 47 | super.setSelected(selected, animated: animated) 48 | 49 | // Configure the view for the selected state 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /V2EX/Cells/LoadMoreCommentCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoadMoreCommentCell.swift 3 | // V2EX 4 | // 5 | // Created by darker on 2017/3/13. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class LoadMoreCommentCell: UITableViewCell { 12 | 13 | @IBOutlet weak var activityIndicatorView: UIActivityIndicatorView! 14 | @IBOutlet weak var titleLabel: UILabel! 15 | 16 | override func awakeFromNib() { 17 | super.awakeFromNib() 18 | // Initialization code 19 | 20 | let selectedView = UIView() 21 | selectedView.backgroundColor = AppStyle.shared.theme.cellSelectedBackgroundColor 22 | self.selectedBackgroundView = selectedView 23 | 24 | backgroundColor = AppStyle.shared.theme.cellBackgroundColor 25 | contentView.backgroundColor = backgroundColor 26 | 27 | if AppStyle.shared.theme == .night { 28 | activityIndicatorView.style = .white 29 | titleLabel.textColor = #colorLiteral(red: 0.1137254902, green: 0.631372549, blue: 0.9490196078, alpha: 1) 30 | } 31 | } 32 | 33 | override func setSelected(_ selected: Bool, animated: Bool) { 34 | super.setSelected(selected, animated: animated) 35 | 36 | // Configure the view for the selected state 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /V2EX/Cells/MessageViewCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MessageViewCell.swift 3 | // V2EX 4 | // 5 | // Created by darker on 2017/3/17. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Kingfisher 11 | 12 | class MessageViewCell: UITableViewCell { 13 | 14 | @IBOutlet weak var replyContentView: UIView! 15 | @IBOutlet weak var avatarView: UIImageView! 16 | @IBOutlet weak var nameLabel: UILabel! 17 | @IBOutlet weak var timeLabel: UILabel! 18 | @IBOutlet weak var replyLabel: UILabel! 19 | @IBOutlet weak var topicLabel: UILabel! 20 | 21 | var message: Message? { 22 | willSet { 23 | if let model = newValue { 24 | avatarView.kf.setImage(with: URL(string: model.sender?.avatar(.large) ?? ""), placeholder: #imageLiteral(resourceName: "avatar_default")) 25 | nameLabel.text = model.sender?.name 26 | timeLabel.text = model.time 27 | 28 | let paragraphStyle = NSMutableParagraphStyle() 29 | paragraphStyle.lineSpacing = 3 30 | 31 | let attributes = [NSAttributedString.Key.font: UIFont.systemFont(ofSize: 13), NSAttributedString.Key.foregroundColor: AppStyle.shared.theme.black102Color, NSAttributedString.Key.paragraphStyle: paragraphStyle] 32 | replyLabel.attributedText = NSAttributedString(string: model.content, attributes: attributes) 33 | 34 | if let title = model.topic?.title { 35 | let titleAttributes = [NSAttributedString.Key.font: UIFont.systemFont(ofSize: 15), NSAttributedString.Key.foregroundColor: AppStyle.shared.theme.black102Color, NSAttributedString.Key.paragraphStyle: paragraphStyle] 36 | topicLabel.attributedText = NSAttributedString(string: title, attributes: titleAttributes) 37 | } 38 | } 39 | } 40 | } 41 | 42 | override func awakeFromNib() { 43 | super.awakeFromNib() 44 | // Initialization code 45 | avatarView.clipsToBounds = true 46 | avatarView.layer.cornerRadius = 4.0 47 | 48 | let selectedView = UIView() 49 | selectedView.backgroundColor = AppStyle.shared.theme.cellSelectedBackgroundColor 50 | self.selectedBackgroundView = selectedView 51 | 52 | self.backgroundColor = AppStyle.shared.theme.cellBackgroundColor 53 | nameLabel.textColor = AppStyle.shared.theme.black64Color 54 | timeLabel.textColor = AppStyle.shared.theme.black153Color 55 | replyContentView.backgroundColor = AppStyle.shared.theme.cellSubBackgroundColor 56 | } 57 | 58 | override func setSelected(_ selected: Bool, animated: Bool) { 59 | super.setSelected(selected, animated: animated) 60 | 61 | // Configure the view for the selected state 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /V2EX/Cells/NodeListViewCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NodeListViewCell.swift 3 | // V2EX 4 | // 5 | // Created by wgh on 2017/4/26. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class NodeListViewCell: UITableViewCell { 12 | 13 | var node: Node? { 14 | willSet { 15 | if let item = newValue { 16 | textLabel?.text = item.name 17 | switch AppStyle.shared.theme { 18 | case .normal: 19 | textLabel?.textColor = item.isCurrent ? #colorLiteral(red: 0, green: 0, blue: 0, alpha: 1) : #colorLiteral(red: 0.3137254902, green: 0.3137254902, blue: 0.3137254902, alpha: 1) 20 | case .night: 21 | textLabel?.textColor = item.isCurrent ? #colorLiteral(red: 0.9019607843, green: 0.9019607843, blue: 0.9019607843, alpha: 1) : #colorLiteral(red: 0.6078431373, green: 0.6862745098, blue: 0.8, alpha: 1) 22 | } 23 | } 24 | } 25 | } 26 | 27 | override func awakeFromNib() { 28 | super.awakeFromNib() 29 | // Initialization code 30 | self.backgroundColor = AppStyle.shared.theme.tableBackgroundColor 31 | self.contentView.backgroundColor = AppStyle.shared.theme.tableBackgroundColor 32 | let selectedView = UIView() 33 | selectedView.backgroundColor = AppStyle.shared.theme.cellSelectedBackgroundColor 34 | self.selectedBackgroundView = selectedView 35 | } 36 | 37 | override func setSelected(_ selected: Bool, animated: Bool) { 38 | super.setSelected(selected, animated: animated) 39 | 40 | // Configure the view for the selected state 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /V2EX/Cells/NodeNavigationViewCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NodeNavigationViewCell.swift 3 | // V2EX 4 | // 5 | // Created by darker on 2017/4/6. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class NodeNavigationViewCell: UITableViewCell { 12 | 13 | @IBOutlet weak var textView: UITextView! 14 | var linkTap: ((TapLink) -> Void)? 15 | 16 | private let css = "a:link, a:visited, a:active { " + 17 | "line-height: 1.6;" + 18 | "text-decoration: none; " + 19 | "word-break: break-all; " + 20 | "}" 21 | 22 | var content: String? { 23 | willSet { 24 | if let content = newValue?.trimmingCharacters(in: .whitespacesAndNewlines) { 25 | let text = content.replacingOccurrences(of: "14px", with: "16px") 26 | let htmlText = "" + text 27 | if let htmlData = htmlText.data(using: .unicode) { 28 | do { 29 | let attributedString = try NSMutableAttributedString(data: htmlData, options: [.documentType: NSAttributedString.DocumentType.html], documentAttributes: nil) 30 | textView.attributedText = attributedString 31 | } catch { 32 | textView.text = content 33 | } 34 | }else { 35 | textView.text = content 36 | } 37 | } 38 | } 39 | } 40 | 41 | override func awakeFromNib() { 42 | super.awakeFromNib() 43 | // Initialization code 44 | textView.linkTextAttributes = convertToOptionalNSAttributedStringKeyDictionary([NSAttributedString.Key.foregroundColor.rawValue: #colorLiteral(red: 0.4666666667, green: 0.5019607843, blue: 0.5294117647, alpha: 1)]) 45 | textView.delegate = self 46 | 47 | if AppStyle.shared.theme == .night { 48 | textView.linkTextAttributes = convertToOptionalNSAttributedStringKeyDictionary([NSAttributedString.Key.foregroundColor.rawValue: #colorLiteral(red: 0.6078431373, green: 0.6862745098, blue: 0.8, alpha: 1)]) 49 | } 50 | backgroundColor = AppStyle.shared.theme.cellBackgroundColor 51 | contentView.backgroundColor = backgroundColor 52 | textView.backgroundColor = backgroundColor 53 | } 54 | 55 | override func setSelected(_ selected: Bool, animated: Bool) { 56 | super.setSelected(selected, animated: animated) 57 | 58 | // Configure the view for the selected state 59 | } 60 | } 61 | 62 | extension NodeNavigationViewCell: UITextViewDelegate { 63 | func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { 64 | 65 | if URL.absoluteString.hasPrefix("applewebdata://") { 66 | let href = URL.path 67 | var name = URL.lastPathComponent 68 | let text = textView.attributedText.string 69 | if let range = characterRange.range(for: text) { 70 | name = String(text[range]) 71 | } 72 | let node = Node(name: name, href: href) 73 | linkTap?(TapLink.node(info: node)) 74 | } 75 | return false 76 | } 77 | } 78 | 79 | // Helper function inserted by Swift 4.2 migrator. 80 | fileprivate func convertToOptionalNSAttributedStringKeyDictionary(_ input: [String: Any]?) -> [NSAttributedString.Key: Any]? { 81 | guard let input = input else { return nil } 82 | return Dictionary(uniqueKeysWithValues: input.map { key, value in (NSAttributedString.Key(rawValue: key), value)}) 83 | } 84 | -------------------------------------------------------------------------------- /V2EX/Cells/NodeTopicsViewCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NodeTopicsViewCell.swift 3 | // V2EX 4 | // 5 | // Created by darker on 2017/3/20. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Kingfisher 11 | 12 | class NodeTopicsViewCell: UITableViewCell { 13 | 14 | @IBOutlet weak var avatarView: UIImageView! 15 | @IBOutlet weak var ownerNameLabel: UILabel! 16 | @IBOutlet weak var titleLabel: UILabel! 17 | @IBOutlet weak var countLabel: UILabel! 18 | 19 | var avatarTap: (() -> Void)? 20 | 21 | var topic: Topic? { 22 | willSet { 23 | if let model = newValue { 24 | avatarView.kf.setImage(with: URL(string: model.owner?.avatar(.large) ?? ""), placeholder: #imageLiteral(resourceName: "avatar_default")) 25 | ownerNameLabel.text = model.owner?.name 26 | countLabel.text = " \(model.replyCount) " 27 | countLabel.isHidden = model.replyCount == "0" 28 | 29 | let paragraphStyle = NSMutableParagraphStyle() 30 | paragraphStyle.lineSpacing = 3 31 | 32 | let attributes = [NSAttributedString.Key.font: UIFont.systemFont(ofSize: 15), NSAttributedString.Key.foregroundColor: AppStyle.shared.theme.black64Color, NSAttributedString.Key.paragraphStyle: paragraphStyle] 33 | titleLabel.attributedText = NSAttributedString(string: model.title, attributes: attributes) 34 | } 35 | } 36 | } 37 | 38 | override func awakeFromNib() { 39 | super.awakeFromNib() 40 | // Initialization code 41 | self.backgroundColor = AppStyle.shared.theme.cellBackgroundColor 42 | contentView.backgroundColor = AppStyle.shared.theme.cellBackgroundColor 43 | let selectedView = UIView() 44 | selectedView.backgroundColor = AppStyle.shared.theme.cellSelectedBackgroundColor 45 | self.selectedBackgroundView = selectedView 46 | 47 | ownerNameLabel.textColor = AppStyle.shared.theme.black102Color 48 | 49 | avatarView.clipsToBounds = true 50 | avatarView.layer.cornerRadius = 4.0 51 | 52 | countLabel.clipsToBounds = true 53 | countLabel.layer.cornerRadius = 9 54 | countLabel.backgroundColor = AppStyle.shared.theme.topicReplyCountBackgroundColor 55 | countLabel.textColor = AppStyle.shared.theme.topicReplyCountTextColor 56 | 57 | avatarView.isUserInteractionEnabled = true 58 | let tap = UITapGestureRecognizer(target: self, action: #selector(avatarTapAction(_:))) 59 | avatarView.addGestureRecognizer(tap) 60 | } 61 | 62 | @objc func avatarTapAction(_ sender: Any) { 63 | avatarTap?() 64 | } 65 | 66 | override func setSelected(_ selected: Bool, animated: Bool) { 67 | super.setSelected(selected, animated: animated) 68 | 69 | // Configure the view for the selected state 70 | } 71 | 72 | } 73 | -------------------------------------------------------------------------------- /V2EX/Cells/ProfileMenuViewCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProfileMenuViewCell.swift 3 | // V2EX 4 | // 5 | // Created by darker on 2017/3/14. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import RxCocoa 11 | import RxSwift 12 | 13 | class ProfileMenuViewCell: UITableViewCell, ThemeUpdating { 14 | 15 | @IBOutlet weak var iconView: UIImageView! 16 | @IBOutlet weak var nameLabel: UILabel! 17 | @IBOutlet weak var unreadLabel: UILabel! 18 | 19 | var unreadCount: Int = 0 { 20 | willSet { 21 | unreadLabel.isHidden = newValue < 1 22 | unreadLabel.text = " \(newValue) " 23 | } 24 | } 25 | 26 | override func awakeFromNib() { 27 | super.awakeFromNib() 28 | 29 | unreadLabel.clipsToBounds = true 30 | unreadLabel.layer.cornerRadius = 9 31 | unreadLabel.isHidden = true 32 | } 33 | 34 | func updateTheme() { 35 | self.backgroundColor = AppStyle.shared.theme.tableBackgroundColor 36 | contentView.backgroundColor = AppStyle.shared.theme.tableBackgroundColor 37 | let selectedView = UIView() 38 | selectedView.backgroundColor = AppStyle.shared.theme.cellSelectedBackgroundColor 39 | self.selectedBackgroundView = selectedView 40 | 41 | nameLabel.textColor = AppStyle.shared.theme.black64Color 42 | } 43 | 44 | func configure(image: UIImage, text: String) { 45 | iconView.image = AppStyle.shared.theme == .night ? image.imageWithTintColor(#colorLiteral(red: 0.6078431373, green: 0.6862745098, blue: 0.8, alpha: 1)) : image 46 | nameLabel.text = text 47 | } 48 | 49 | override func setSelected(_ selected: Bool, animated: Bool) { 50 | super.setSelected(selected, animated: animated) 51 | 52 | // Configure the view for the selected state 53 | } 54 | 55 | } 56 | 57 | extension Reactive where Base: ProfileMenuViewCell { 58 | var unread: Binder { 59 | return Binder(self.base) { view, value in 60 | view.unreadCount = value 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /V2EX/Cells/TimelineReplyViewCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TimelineReplyViewCell.swift 3 | // V2EX 4 | // 5 | // Created by darker on 2017/3/15. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class TimelineReplyViewCell: UITableViewCell { 12 | 13 | @IBOutlet weak var titleContentView: UIView! 14 | @IBOutlet weak var topicTitleLabel: UILabel! 15 | @IBOutlet weak var replyContentLabel: UILabel! 16 | @IBOutlet weak var timeLabel: UILabel! 17 | 18 | var reply: Reply? { 19 | willSet { 20 | if let model = newValue { 21 | timeLabel.text = model.topic?.lastReplyTime 22 | 23 | let paragraphStyle = NSMutableParagraphStyle() 24 | paragraphStyle.lineSpacing = 3 25 | 26 | let attributes = [NSAttributedString.Key.font: UIFont.systemFont(ofSize: 15), NSAttributedString.Key.foregroundColor: AppStyle.shared.theme.black102Color, NSAttributedString.Key.paragraphStyle: paragraphStyle] 27 | replyContentLabel.attributedText = NSAttributedString(string: model.content, attributes: attributes) 28 | 29 | if let title = model.topic?.title { 30 | let titleAttributes = [NSAttributedString.Key.font: UIFont.systemFont(ofSize: 13), NSAttributedString.Key.foregroundColor: AppStyle.shared.theme.black102Color, NSAttributedString.Key.paragraphStyle: paragraphStyle] 31 | topicTitleLabel.attributedText = NSAttributedString(string: title, attributes: titleAttributes) 32 | } 33 | } 34 | } 35 | } 36 | 37 | override func awakeFromNib() { 38 | super.awakeFromNib() 39 | // Initialization code 40 | 41 | let selectedView = UIView() 42 | selectedView.backgroundColor = AppStyle.shared.theme.cellSelectedBackgroundColor 43 | self.selectedBackgroundView = selectedView 44 | 45 | self.backgroundColor = AppStyle.shared.theme.cellBackgroundColor 46 | timeLabel.textColor = AppStyle.shared.theme.black153Color 47 | titleContentView.backgroundColor = AppStyle.shared.theme.cellSubBackgroundColor 48 | 49 | } 50 | 51 | override func setSelected(_ selected: Bool, animated: Bool) { 52 | super.setSelected(selected, animated: animated) 53 | 54 | // Configure the view for the selected state 55 | } 56 | 57 | } 58 | -------------------------------------------------------------------------------- /V2EX/Cells/TimelineTopicViewCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TimelineTopicViewCell.swift 3 | // V2EX 4 | // 5 | // Created by darker on 2017/3/15. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class TimelineTopicViewCell: UITableViewCell { 12 | 13 | @IBOutlet weak var titleLabel: UILabel! 14 | @IBOutlet weak var timeLabel: UILabel! 15 | @IBOutlet weak var countLabel: UILabel! 16 | 17 | var topic: Topic? { 18 | willSet { 19 | if let model = newValue { 20 | timeLabel.text = model.lastReplyTime 21 | countLabel.text = " \(model.replyCount) " 22 | countLabel.isHidden = model.replyCount == "0" 23 | 24 | let paragraphStyle = NSMutableParagraphStyle() 25 | paragraphStyle.lineSpacing = 3 26 | 27 | let attributes = [NSAttributedString.Key.font: UIFont.systemFont(ofSize: 15), NSAttributedString.Key.foregroundColor: AppStyle.shared.theme.black102Color, NSAttributedString.Key.paragraphStyle: paragraphStyle] 28 | titleLabel.attributedText = NSAttributedString(string: model.title, attributes: attributes) 29 | } 30 | } 31 | } 32 | 33 | override func awakeFromNib() { 34 | super.awakeFromNib() 35 | // Initialization code 36 | countLabel.clipsToBounds = true 37 | countLabel.layer.cornerRadius = 9 38 | 39 | let selectedView = UIView() 40 | selectedView.backgroundColor = AppStyle.shared.theme.cellSelectedBackgroundColor 41 | self.selectedBackgroundView = selectedView 42 | 43 | self.backgroundColor = AppStyle.shared.theme.cellBackgroundColor 44 | timeLabel.textColor = AppStyle.shared.theme.black153Color 45 | countLabel.backgroundColor = AppStyle.shared.theme.topicReplyCountBackgroundColor 46 | countLabel.textColor = AppStyle.shared.theme.topicReplyCountTextColor 47 | } 48 | 49 | override func setSelected(_ selected: Bool, animated: Bool) { 50 | super.setSelected(selected, animated: animated) 51 | 52 | // Configure the view for the selected state 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /V2EX/Cells/TopicViewCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TopicViewCell.swift 3 | // V2EX 4 | // 5 | // Created by darker on 2017/3/2. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Kingfisher 11 | import RxSwift 12 | 13 | class TopicViewCell: UITableViewCell, ThemeUpdating { 14 | 15 | @IBOutlet weak var avatarView: UIImageView! 16 | @IBOutlet weak var nodeLabel: UILabel! 17 | @IBOutlet weak var ownerNameLabel: UILabel! 18 | @IBOutlet weak var titleLabel: UILabel! 19 | @IBOutlet weak var timeLabel: UILabel! 20 | @IBOutlet weak var countLabel: UILabel! 21 | 22 | var linkTap: ((TapLink) -> Void)? 23 | 24 | var topic: Topic? { 25 | willSet { 26 | if let model = newValue { 27 | avatarView.kf.setImage(with: URL(string: model.owner?.avatar(.large) ?? ""), placeholder: #imageLiteral(resourceName: "avatar_default")) 28 | nodeLabel.text = " " + (model.node?.name ?? "") + " " 29 | ownerNameLabel.text = model.owner?.name 30 | timeLabel.text = model.lastReplyTime 31 | countLabel.text = " \(model.replyCount) " 32 | countLabel.isHidden = model.replyCount == "0" 33 | 34 | let paragraphStyle = NSMutableParagraphStyle() 35 | paragraphStyle.lineSpacing = 3 36 | 37 | let attributes = [NSAttributedString.Key.font: UIFont.systemFont(ofSize: 15), NSAttributedString.Key.foregroundColor: AppStyle.shared.theme.black102Color, NSAttributedString.Key.paragraphStyle: paragraphStyle] 38 | titleLabel.attributedText = NSAttributedString(string: model.title, attributes: attributes) 39 | } 40 | } 41 | } 42 | 43 | private let disposeBag = DisposeBag() 44 | 45 | override func awakeFromNib() { 46 | super.awakeFromNib() 47 | // Initialization code 48 | avatarView.clipsToBounds = true 49 | avatarView.layer.cornerRadius = 4.0 50 | nodeLabel.clipsToBounds = true 51 | nodeLabel.layer.cornerRadius = 4.0 52 | 53 | countLabel.clipsToBounds = true 54 | countLabel.layer.cornerRadius = 9 55 | 56 | avatarView.isUserInteractionEnabled = true 57 | let avatarTap = UITapGestureRecognizer(target: self, action: #selector(userTapAction(_:))) 58 | avatarView.addGestureRecognizer(avatarTap) 59 | 60 | ownerNameLabel.isUserInteractionEnabled = true 61 | let nameTap = UITapGestureRecognizer(target: self, action: #selector(userTapAction(_:))) 62 | ownerNameLabel.addGestureRecognizer(nameTap) 63 | 64 | nodeLabel.isUserInteractionEnabled = true 65 | let nodeTap = UITapGestureRecognizer(target: self, action: #selector(nodeTapAction(_:))) 66 | nodeLabel.addGestureRecognizer(nodeTap) 67 | 68 | updateTheme() 69 | AppStyle.shared.themeUpdateVariable.asObservable().subscribe(onNext: { update in 70 | if update { 71 | self.updateTheme() 72 | } 73 | }).disposed(by: disposeBag) 74 | } 75 | 76 | func updateTheme() { 77 | let selectedView = UIView() 78 | selectedView.backgroundColor = AppStyle.shared.theme.cellSelectedBackgroundColor 79 | self.selectedBackgroundView = selectedView 80 | 81 | self.backgroundColor = AppStyle.shared.theme.cellBackgroundColor 82 | 83 | ownerNameLabel.textColor = AppStyle.shared.theme.black102Color 84 | nodeLabel.backgroundColor = AppStyle.shared.theme.topicCellNodeBackgroundColor 85 | nodeLabel.textColor = AppStyle.shared.theme.black153Color 86 | timeLabel.textColor = AppStyle.shared.theme.black153Color 87 | countLabel.backgroundColor = AppStyle.shared.theme.topicReplyCountBackgroundColor 88 | countLabel.textColor = AppStyle.shared.theme.topicReplyCountTextColor 89 | 90 | if let data = topic { 91 | topic = data 92 | } 93 | } 94 | 95 | @objc func userTapAction(_ sender: Any) { 96 | if let user = topic?.owner { 97 | linkTap?(TapLink.user(info: user)) 98 | } 99 | } 100 | 101 | @objc func nodeTapAction(_ sender: Any) { 102 | if let node = topic?.node { 103 | linkTap?(TapLink.node(info: node)) 104 | } 105 | } 106 | 107 | override func setSelected(_ selected: Bool, animated: Bool) { 108 | super.setSelected(selected, animated: animated) 109 | 110 | // Configure the view for the selected state 111 | } 112 | 113 | } 114 | -------------------------------------------------------------------------------- /V2EX/Extensions/Error.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Error.swift 3 | // V2EX 4 | // 5 | // Created by darker on 2017/3/29. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Moya 11 | 12 | enum NetError: Swift.Error { 13 | case message(text: String) 14 | } 15 | 16 | extension Swift.Error { 17 | var message: String { 18 | if self is NetError { 19 | switch (self as! NetError) { 20 | case let .message(text): 21 | return text 22 | } 23 | }else if self is MoyaError { 24 | return (self as! MoyaError).errorDescription ?? "" 25 | } 26 | return localizedDescription 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /V2EX/Extensions/NSLayoutManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSLayoutManager.swift 3 | // V2EX 4 | // 5 | // Created by wgh on 2017/6/30. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | extension NSLayoutManager { 12 | 13 | func rangesForAttachment(attachment: NSTextAttachment) -> [NSRange]? { 14 | guard let textStorage = textStorage else { 15 | return nil 16 | } 17 | let range = NSRange(location: 0, length: textStorage.length) 18 | var ranges: [NSRange] = [] 19 | textStorage.enumerateAttribute(NSAttributedString.Key.attachment, in: range, options: []) { (value, effectiveRange, nil) in 20 | if let value = value as? NSTextAttachment, value == attachment { 21 | ranges.append(effectiveRange) 22 | } 23 | } 24 | 25 | return ranges.isEmpty ? nil : ranges 26 | } 27 | 28 | func setNeedsLayout(forAttachment attachment: NSTextAttachment) { 29 | if let ranges = rangesForAttachment(attachment: attachment) { 30 | 31 | ranges.reversed().forEach({range in 32 | self.invalidateLayout(forCharacterRange: range, actualCharacterRange: nil) 33 | self.invalidateDisplay(forCharacterRange: range) 34 | }) 35 | 36 | } 37 | } 38 | 39 | func setNeedsDisplay(forAttachment attachment: NSTextAttachment) { 40 | if let ranges = rangesForAttachment(attachment: attachment) { 41 | ranges.reversed().forEach({range in 42 | self.invalidateDisplay(forCharacterRange: range) 43 | }) 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /V2EX/Extensions/ReusableView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ReusableView.swift 3 | // V2EX 4 | // 5 | // Created by darker on 2017/3/2. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | extension UIViewController { 12 | static var segueId: String { 13 | return String(describing: self) 14 | } 15 | } 16 | 17 | protocol ReusableView: class { 18 | static var reuseId: String {get} 19 | } 20 | 21 | extension ReusableView where Self: UIView { 22 | static var reuseId: String { 23 | return String(describing: self) 24 | } 25 | } 26 | 27 | extension UICollectionReusableView: ReusableView { 28 | 29 | } 30 | 31 | extension UICollectionView { 32 | func register(_: T.Type) { 33 | register(T.self, forCellWithReuseIdentifier: T.reuseId) 34 | } 35 | 36 | func dequeueReusableCell(for indexPath: IndexPath) -> T { 37 | guard let cell = dequeueReusableCell(withReuseIdentifier: T.reuseId, for: indexPath) as? T else { 38 | fatalError("Could not dequeue cell with identifier: \(T.reuseId)") 39 | } 40 | return cell 41 | } 42 | 43 | func dequeueReusableSupplementaryView(ofKind: String, indexPath: IndexPath) -> T { 44 | guard let view = dequeueReusableSupplementaryView(ofKind: ofKind, withReuseIdentifier: T.reuseId, for: indexPath) as? T else { 45 | fatalError("Could not dequeue reusableSupplementaryView with identifier: \(T.reuseId)") 46 | } 47 | return view 48 | } 49 | } 50 | 51 | extension UITableViewCell: ReusableView { 52 | 53 | } 54 | 55 | extension UITableView { 56 | 57 | func register(for headerFooter: T.Type) where T: ReusableView { 58 | let nib = UINib(nibName: T.nibName, bundle: Bundle(for: T.self)) 59 | register(nib, forHeaderFooterViewReuseIdentifier: T.reuseId) 60 | } 61 | 62 | func register(for headerFooter: T.Type) { 63 | register(T.self, forHeaderFooterViewReuseIdentifier: T.reuseId) 64 | } 65 | 66 | func dequeueReusableCell() -> T { 67 | guard let cell = dequeueReusableCell(withIdentifier: T.reuseId) as? T else { 68 | fatalError("Could not dequeue cell with identifier: \(T.reuseId)") 69 | } 70 | return cell 71 | } 72 | 73 | func dequeueReusableCell(for indexPath: IndexPath) -> T { 74 | guard let cell = dequeueReusableCell(withIdentifier: T.reuseId, for: indexPath) as? T else { 75 | fatalError("Could not dequeue cell with identifier: \(T.reuseId)") 76 | } 77 | return cell 78 | } 79 | 80 | func dequeueReusableHeaderFooterView() -> T { 81 | guard let view = dequeueReusableHeaderFooterView(withIdentifier: T.reuseId) as? T else { 82 | fatalError("Could not dequeue HeaderFooter with identifier: \(T.reuseId)") 83 | } 84 | return view 85 | } 86 | } 87 | 88 | // MARK: - NibLoadable 89 | protocol NibLoadableView: class { 90 | static var nibName: String {get} 91 | } 92 | 93 | extension NibLoadableView where Self: UIView { 94 | static var nibName: String { 95 | return String(describing: self) 96 | } 97 | } 98 | 99 | 100 | extension UITableViewHeaderFooterView: NibLoadableView, ReusableView { 101 | 102 | } 103 | 104 | -------------------------------------------------------------------------------- /V2EX/Extensions/String.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String.swift 3 | // V2EX 4 | // 5 | // Created by darker on 2017/3/23. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension String { 12 | func nsRange(from range: Range) -> NSRange { 13 | guard let from = range.lowerBound.samePosition(in: utf16), let to = range.upperBound.samePosition(in: utf16) else { 14 | return NSRange() 15 | } 16 | return NSRange(location: utf16.distance(from: utf16.startIndex, to: from), 17 | length: utf16.distance(from: from, to: to)) 18 | } 19 | 20 | var nsRange: NSRange { 21 | return NSRange(location: 0, length: self.count) 22 | } 23 | } 24 | 25 | extension NSRange { 26 | func range(for str: String) -> Range? { 27 | guard location != NSNotFound else { return nil } 28 | guard let fromUTFIndex = str.utf16.index(str.utf16.startIndex, offsetBy: location, limitedBy: str.utf16.endIndex) else { return nil } 29 | guard let toUTFIndex = str.utf16.index(fromUTFIndex, offsetBy: length, limitedBy: str.utf16.endIndex) else { return nil } 30 | guard let fromIndex = String.Index(fromUTFIndex, within: str) else { return nil } 31 | guard let toIndex = String.Index(toUTFIndex, within: str) else { return nil } 32 | return fromIndex ..< toIndex 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /V2EX/Extensions/UIImage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIImage.swift 3 | // V2EX 4 | // 5 | // Created by darker on 2017/3/21. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import ImageIO 11 | 12 | extension UIImage { 13 | func thumbnailForMaxPixelSize(_ size: UInt) -> UIImage { 14 | if let imageData = self.jpegData(compressionQuality: 1.0), 15 | let sourceRef = CGImageSourceCreateWithData(imageData as CFData, nil) { 16 | 17 | let options: [NSString: Any] = [ 18 | kCGImageSourceCreateThumbnailWithTransform: true, 19 | kCGImageSourceCreateThumbnailFromImageAlways: true, 20 | kCGImageSourceThumbnailMaxPixelSize: size] 21 | 22 | if let imageRef = CGImageSourceCreateThumbnailAtIndex(sourceRef, 0, options as CFDictionary?) { 23 | return UIImage(cgImage: imageRef) 24 | } 25 | return self 26 | } 27 | return self 28 | } 29 | 30 | convenience init(color: UIColor, size: CGSize) { 31 | UIGraphicsBeginImageContextWithOptions(size, false, 0) 32 | 33 | color.setFill() 34 | UIBezierPath(rect: CGRect(origin: CGPoint.zero, size: size)).fill() 35 | 36 | let image = UIGraphicsGetImageFromCurrentImageContext() 37 | UIGraphicsEndImageContext() 38 | 39 | self.init(cgImage: image!.cgImage!) 40 | } 41 | 42 | class func imageWithColor(_ color: UIColor, size: CGSize) -> UIImage { 43 | UIGraphicsBeginImageContextWithOptions(size, false, 0) 44 | 45 | color.setFill() 46 | UIBezierPath(rect: CGRect(origin: CGPoint.zero, size: size)).fill() 47 | 48 | let image = UIGraphicsGetImageFromCurrentImageContext() 49 | UIGraphicsEndImageContext() 50 | return image! 51 | } 52 | 53 | func imageWithTintColor(_ tintColor: UIColor, blendMode: CGBlendMode = .destinationIn) -> UIImage { 54 | UIGraphicsBeginImageContextWithOptions(size, false, 0.0) 55 | tintColor.setFill() 56 | 57 | let rect = CGRect(origin: CGPoint.zero, size: size) 58 | UIRectFill(rect) 59 | 60 | //Draw the tinted image in context 61 | draw(in: rect, blendMode: blendMode, alpha: 1.0) 62 | if blendMode != .destinationIn { 63 | draw(in: rect, blendMode: .destinationIn, alpha: 1.0) 64 | } 65 | 66 | let tintedImage = UIGraphicsGetImageFromCurrentImageContext() 67 | UIGraphicsEndImageContext() 68 | return tintedImage! 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /V2EX/Extensions/UITableViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UITableViewController.swift 3 | // V2EX 4 | // 5 | // Created by wgh on 2017/4/27. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | protocol ThemeUpdating { 12 | func updateTheme() 13 | } 14 | 15 | extension UIViewController: ThemeUpdating { 16 | @objc func updateTheme() { 17 | 18 | } 19 | 20 | func showLoginAlert(isPopBack: Bool = false) { 21 | let alert = UIAlertController(title: "需要您登录V2EX", message: nil, preferredStyle: .alert) 22 | alert.addAction(UIAlertAction(title: "取消", style: .cancel, handler: {_ in 23 | if isPopBack { 24 | self.navigationController?.popViewController(animated: true) 25 | } 26 | })) 27 | alert.addAction(UIAlertAction(title: "登录", style: .default, handler: {_ in 28 | self.drawerViewController?.performSegue(withIdentifier: LoginViewController.segueId, sender: nil) 29 | })) 30 | present(alert, animated: true, completion: nil) 31 | 32 | } 33 | } 34 | 35 | extension UITableViewController { 36 | override func updateTheme() { 37 | switch tableView.style { 38 | case .plain: 39 | tableView?.backgroundColor = AppStyle.shared.theme.tableBackgroundColor 40 | case .grouped: 41 | tableView?.backgroundColor = AppStyle.shared.theme.tableGroupBackgroundColor 42 | default: 43 | break 44 | } 45 | tableView?.separatorColor = AppStyle.shared.theme.separatorColor 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /V2EX/Models/Account.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Account.swift 3 | // V2EX 4 | // 5 | // Created by darker on 2017/3/6. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import RxSwift 11 | import Moya 12 | import Kanna 13 | 14 | struct Account { 15 | let isDailyRewards = Variable(false) //领取每日奖励 16 | let unreadCount = Variable(0) 17 | 18 | let user = Variable(nil) 19 | let isLoggedIn = Variable(false) 20 | 21 | var privacy: Privacy = Privacy() 22 | 23 | private let disposeBag = DisposeBag() 24 | 25 | static var shared = Account() 26 | private init() { 27 | } 28 | 29 | mutating func logout() { 30 | isLoggedIn.value = false 31 | user.value = nil 32 | HTTPCookieStorage.shared.cookies?.forEach({ cookie in 33 | HTTPCookieStorage.shared.deleteCookie(cookie) 34 | }) 35 | 36 | API.provider.request(.once).flatMapLatest { response -> Observable in 37 | if let once = HTMLParser.shared.once(html: response.data) { 38 | return API.provider.request(API.logout(once: once)) 39 | }else { 40 | return Observable.error(NetError.message(text: "获取once失败")) 41 | } 42 | }.share(replay: 1).subscribe(onNext: { response in 43 | 44 | }, onError: {error in 45 | 46 | }).disposed(by: disposeBag) 47 | } 48 | 49 | func redeemDailyRewards() -> Observable { 50 | return API.provider.request(.once).flatMapLatest { response -> Observable in 51 | if let once = HTMLParser.shared.once(html: response.data) { 52 | return API.provider.request(.dailyRewards(once: once)).flatMapLatest({ resp-> Observable in 53 | do { 54 | let html = try HTML(html: resp.data, encoding: .utf8) 55 | let path = html.xpath("//body/div[@id='Wrapper']/div[@class='content']/div[@class='box']/div[@class='message']") 56 | if let content = path.first?.content, content.contains("已成功领取") { 57 | return Observable.just(true) 58 | }else { 59 | return Observable.error(NetError.message(text: "领取奖励失败")) 60 | } 61 | } catch { 62 | return Observable.error(NetError.message(text: "请求获取失败")) 63 | } 64 | 65 | }).share(replay: 1) 66 | }else { 67 | return Observable.error(NetError.message(text: "获取once失败")) 68 | } 69 | }.share(replay: 1) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /V2EX/Models/AllPostsSection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AllPostsSection.swift 3 | // V2EX 4 | // 5 | // Created by darker on 2017/3/15. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import RxDataSources 11 | 12 | struct AllPostsSection { 13 | var type: SectionType 14 | var data: [SectionTimelineItem] 15 | 16 | init(type: SectionType, data: [SectionTimelineItem]) { 17 | self.type = type 18 | self.data = data 19 | } 20 | } 21 | 22 | extension AllPostsSection: AnimatableSectionModelType { 23 | typealias Identity = SectionType 24 | typealias Item = SectionTimelineItem 25 | init(original: AllPostsSection, items: [Item]) { 26 | self = original 27 | self.data = items 28 | } 29 | 30 | var identity: SectionType { 31 | return type 32 | } 33 | 34 | var items: [SectionTimelineItem] { 35 | return data 36 | } 37 | } 38 | 39 | extension SectionTimelineItem: IdentifiableType, Equatable { 40 | typealias Identity = String 41 | 42 | var identity: String { 43 | switch self { 44 | case let .topicItem(topic): 45 | return topic.href 46 | case let .replyItem(reply): 47 | return reply.topic?.href ?? "" 48 | } 49 | } 50 | 51 | static func == (lhs: SectionTimelineItem, rhs: SectionTimelineItem) -> Bool { 52 | return lhs.identity == lhs.identity 53 | } 54 | } 55 | 56 | extension AllPostsSection: Equatable { 57 | static func == (lhs: AllPostsSection, rhs: AllPostsSection) -> Bool { 58 | return lhs.type == rhs.type && lhs.items == rhs.items 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /V2EX/Models/Comment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Comment.swift 3 | // V2EX 4 | // 5 | // Created by darker on 2017/3/8. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct Comment { 12 | var id: String = "" 13 | var content: String = "" 14 | var time: String = "" 15 | var thanks: String = "0" 16 | var number: String = "0" 17 | var user: User? 18 | } 19 | -------------------------------------------------------------------------------- /V2EX/Models/FavoriteItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FavoriteItem.swift 3 | // V2EX 4 | // 5 | // Created by darker on 2017/3/20. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | enum FavoriteItem { 12 | case topicItem(item: Topic) 13 | case followingItem(item: Topic) 14 | case nodeItem(item: Node) 15 | } 16 | 17 | -------------------------------------------------------------------------------- /V2EX/Models/Message.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Message.swift 3 | // V2EX 4 | // 5 | // Created by darker on 2017/3/17. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// 消息提醒 12 | struct Message { 13 | var sender: User? 14 | var topic: Topic? 15 | var time: String = "" 16 | var content: String = "" 17 | 18 | } 19 | -------------------------------------------------------------------------------- /V2EX/Models/Node.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Node.swift 3 | // V2EX 4 | // 5 | // Created by darker on 2017/3/1. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct Node { 12 | var name: String = "" 13 | var href: String = "" 14 | var isCurrent: Bool = false 15 | 16 | var icon: String = "" 17 | var comments: Int = 0 18 | 19 | init(name: String, href: String, isCurrent: Bool = false, icon: String = "", comments: Int = 0) { 20 | self.name = name 21 | self.href = href 22 | self.isCurrent = isCurrent 23 | self.icon = icon 24 | self.comments = comments 25 | } 26 | } 27 | 28 | extension Node { 29 | var iconURLString: String { 30 | return "https:" + icon 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /V2EX/Models/Privacy.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Privacy.swift 3 | // V2EX 4 | // 5 | // Created by darker on 2017/3/22. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct Privacy { 12 | // 谁可以看到我的在线状态 0所有人 1已登录用户 2只有我自己 13 | var online: Int = 0 14 | // 谁可以看到我的主题列表 0所有人 1已登录用户 2只有我自己 15 | var topic: Int = 0 16 | // 是否允许搜索引擎索引我的主题 17 | var search: Bool = true 18 | } 19 | -------------------------------------------------------------------------------- /V2EX/Models/Reply.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Reply.swift 3 | // V2EX 4 | // 5 | // Created by darker on 2017/3/14. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// 个人timeline回复 12 | struct Reply { 13 | var content: String = "" 14 | var topic: Topic? 15 | } 16 | -------------------------------------------------------------------------------- /V2EX/Models/TapLink.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TapLink.swift 3 | // V2EX 4 | // 5 | // Created by darker on 2017/3/31. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum TapLink { 12 | case user(info: User) 13 | case node(info: Node) 14 | case topic(info: Topic) 15 | case image(src: String) 16 | case web(url: URL) 17 | } 18 | -------------------------------------------------------------------------------- /V2EX/Models/TimelineSection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TimelineSection.swift 3 | // V2EX 4 | // 5 | // Created by darker on 2017/3/14. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import RxDataSources 11 | 12 | enum TimelineSection { 13 | case topic(title: String, privacy: String, moreHref: String, items: [SectionTimelineItem]) 14 | case reply(title: String, moreHref: String, items: [SectionTimelineItem]) 15 | } 16 | 17 | enum SectionTimelineItem { 18 | case topicItem(topic: Topic) 19 | case replyItem(reply: Reply) 20 | } 21 | 22 | extension TimelineSection: SectionModelType { 23 | typealias Item = SectionTimelineItem 24 | 25 | var items: [SectionTimelineItem] { 26 | switch self { 27 | case let .topic(_, _, _, items): 28 | return items.map({$0}) 29 | case let .reply(_, _ , items): 30 | return items.map({$0}) 31 | } 32 | } 33 | 34 | init(original: TimelineSection, items: [Item]) { 35 | switch original { 36 | case let .topic(title, privacy, moreHref, items): 37 | self = .topic(title: title, privacy: privacy, moreHref: moreHref, items: items) 38 | case let .reply(title, moreHref, items): 39 | self = .reply(title: title, moreHref: moreHref, items: items) 40 | } 41 | } 42 | } 43 | 44 | extension TimelineSection { 45 | var title: String { 46 | switch self { 47 | case let .topic(title, _, _, _): 48 | return title 49 | case let .reply(title, _, _): 50 | return title 51 | } 52 | } 53 | 54 | var privacy: String { 55 | switch self { 56 | case let .topic(_,privacy, _, _): 57 | return privacy 58 | default: 59 | return "" 60 | } 61 | } 62 | 63 | var moreHref: String { 64 | switch self { 65 | case let .topic(_, _, moreHref, _): 66 | return moreHref 67 | case let .reply(_, moreHref, _): 68 | return moreHref 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /V2EX/Models/Topic.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Topic.swift 3 | // V2EX 4 | // 5 | // Created by darker on 2017/3/1. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct Topic { 12 | var title: String = "" 13 | var content: String = "" 14 | var href: String = "" 15 | var owner: User? 16 | var node: Node? 17 | var lastReplyTime: String = "" 18 | var lastReplyUser: User? 19 | var replyCount: String = "0" 20 | var creatTime: String = "" 21 | var token: String = "" 22 | var isFavorite: Bool = false 23 | var isThank: Bool = false 24 | 25 | init(title: String = "", content: String = "", href: String = "", owner: User? = nil, node: Node? = nil, lastReplyTime: String = "", lastReplyUser: User? = nil, replyCount: String = "0", creatTime: String = "", token: String = "", isFavorite: Bool = false, isThank: Bool = false) { 26 | self.title = title 27 | self.content = content 28 | self.href = href 29 | self.owner = owner 30 | self.node = node 31 | self.lastReplyTime = lastReplyTime 32 | self.lastReplyUser = lastReplyUser 33 | self.replyCount = replyCount 34 | self.creatTime = creatTime 35 | self.token = token 36 | self.isFavorite = isFavorite 37 | self.isThank = isThank 38 | } 39 | } 40 | 41 | extension Topic { 42 | var id: String { 43 | if href.contains("#") { 44 | return href.components(separatedBy: "#").first?.replacingOccurrences(of: "/t/", with: "") ?? "" 45 | } 46 | return href.replacingOccurrences(of: "/t/", with: "") 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /V2EX/Models/TopicDetailsSection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TopicDetailsSection.swift 3 | // V2EX 4 | // 5 | // Created by darker on 2017/3/7. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import RxDataSources 11 | 12 | enum SectionType { 13 | case more, data 14 | } 15 | 16 | struct TopicDetailsSection { 17 | var type: SectionType 18 | var comments: [Comment] 19 | 20 | init(type: SectionType, comments: [Comment]) { 21 | self.type = type 22 | self.comments = comments 23 | } 24 | } 25 | 26 | extension TopicDetailsSection: AnimatableSectionModelType { 27 | typealias Identity = SectionType 28 | typealias Item = Comment 29 | init(original: TopicDetailsSection, items: [Item]) { 30 | self = original 31 | self.comments = items 32 | } 33 | 34 | var identity: SectionType { 35 | return type 36 | } 37 | 38 | var items: [Comment] { 39 | return comments 40 | } 41 | } 42 | 43 | extension Comment: IdentifiableType, Equatable { 44 | typealias Identity = String 45 | 46 | var identity: String { 47 | return number 48 | } 49 | 50 | static func == (lhs: Comment, rhs: Comment) -> Bool { 51 | return lhs.number == rhs.number 52 | } 53 | } 54 | 55 | extension TopicDetailsSection: Equatable { 56 | static func == (lhs: TopicDetailsSection, rhs: TopicDetailsSection) -> Bool { 57 | return lhs.type == rhs.type && lhs.items == rhs.items 58 | } 59 | } 60 | 61 | 62 | -------------------------------------------------------------------------------- /V2EX/Models/TopicListSection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TopicListSection.swift 3 | // V2EX 4 | // 5 | // Created by darker on 2017/3/29. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import RxDataSources 10 | 11 | struct TopicListSection { 12 | var header: String 13 | var topics: [Topic] 14 | 15 | init(header: String, topics: [Topic]) { 16 | self.header = header 17 | self.topics = topics 18 | } 19 | } 20 | 21 | extension TopicListSection: AnimatableSectionModelType { 22 | typealias Identity = String 23 | typealias Item = Topic 24 | init(original: TopicListSection, items: [Item]) { 25 | self = original 26 | self.topics = items 27 | } 28 | 29 | var identity: String { 30 | return header 31 | } 32 | 33 | var items: [Topic] { 34 | return topics 35 | } 36 | } 37 | 38 | extension Topic: IdentifiableType, Equatable { 39 | typealias Identity = String 40 | 41 | var identity: String { 42 | return id 43 | } 44 | 45 | static func == (lhs: Topic, rhs: Topic) -> Bool { 46 | return lhs.id == rhs.id 47 | } 48 | } 49 | 50 | extension TopicListSection: Equatable { 51 | static func == (lhs: TopicListSection, rhs: TopicListSection) -> Bool { 52 | return lhs.identity == rhs.identity && lhs.items == rhs.items 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /V2EX/Models/User.swift: -------------------------------------------------------------------------------- 1 | // 2 | // User.swift 3 | // V2EX 4 | // 5 | // Created by darker on 2017/3/2. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct User { 12 | var name: String = "" 13 | var href: String = "" 14 | var src: String = "" 15 | 16 | init(name: String, href: String, src: String) { 17 | self.name = name 18 | self.href = href 19 | self.src = src 20 | } 21 | } 22 | 23 | extension User { 24 | enum Avatar: String { 25 | case normal = "_normal.", mini = "_mini.", large = "_large." 26 | } 27 | 28 | var srcURLString: String { 29 | return "https:" + src 30 | } 31 | 32 | func avatar(_ type: Avatar) -> String { 33 | let arr = ["_normal.", "_mini.", "_large."] 34 | if let index = arr.firstIndex(where: {srcURLString.hasSuffix($0)}) { 35 | return srcURLString.replacingOccurrences(of: arr[index], with: type.rawValue) 36 | } 37 | return srcURLString 38 | } 39 | } 40 | 41 | -------------------------------------------------------------------------------- /V2EX/Services/ActivityIndicator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ActivityIndicator.swift 3 | // RxExample 4 | // 5 | // Created by Krunoslav Zaher on 10/18/15. 6 | // Copyright © 2015 Krunoslav Zaher. All rights reserved. 7 | // 8 | 9 | #if !RX_NO_MODULE 10 | import RxSwift 11 | import RxCocoa 12 | #endif 13 | 14 | private struct ActivityToken : ObservableConvertibleType, Disposable { 15 | private let _source: Observable 16 | private let _dispose: Cancelable 17 | 18 | init(source: Observable, disposeAction: @escaping () -> ()) { 19 | _source = source 20 | _dispose = Disposables.create(with: disposeAction) 21 | } 22 | 23 | func dispose() { 24 | _dispose.dispose() 25 | } 26 | 27 | func asObservable() -> Observable { 28 | return _source 29 | } 30 | } 31 | 32 | /** 33 | Enables monitoring of sequence computation. 34 | 35 | If there is at least one sequence computation in progress, `true` will be sent. 36 | When all activities complete `false` will be sent. 37 | */ 38 | public class ActivityIndicator : SharedSequenceConvertibleType { 39 | public typealias E = Bool 40 | public typealias SharingStrategy = DriverSharingStrategy 41 | 42 | private let _lock = NSRecursiveLock() 43 | private let _variable = Variable(0) 44 | private let _loading: SharedSequence 45 | 46 | public init() { 47 | _loading = _variable.asDriver() 48 | .map { $0 > 0 } 49 | .distinctUntilChanged() 50 | } 51 | 52 | fileprivate func trackActivityOfObservable(_ source: O) -> Observable { 53 | return Observable.using({ () -> ActivityToken in 54 | self.increment() 55 | return ActivityToken(source: source.asObservable(), disposeAction: self.decrement) 56 | }) { t in 57 | return t.asObservable() 58 | } 59 | } 60 | 61 | private func increment() { 62 | _lock.lock() 63 | _variable.value = _variable.value + 1 64 | _lock.unlock() 65 | } 66 | 67 | private func decrement() { 68 | _lock.lock() 69 | _variable.value = _variable.value - 1 70 | _lock.unlock() 71 | } 72 | 73 | public func asSharedSequence() -> SharedSequence { 74 | return _loading 75 | } 76 | } 77 | 78 | extension ObservableConvertibleType { 79 | public func trackActivity(_ activityIndicator: ActivityIndicator) -> Observable { 80 | return activityIndicator.trackActivityOfObservable(self) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /V2EX/Supporting Files/ABOUT.html: -------------------------------------------------------------------------------- 1 | 2 |
V2EX 是创意工作者们的社区。这里目前汇聚了超过 110,000 名主要来自互联网行业、游戏行业和媒体行业的创意工作者。V2EX 希望能够成为创意工作者们的生活和事业的一部分。 3 |
4 |
希望大家能够多多分享自己正在做的有趣事物、交流想法,在这里找到朋友甚至新的机会。并且,最重要的是,在这一切的过程中,保持对他人的友善。 5 |
6 |
为了保持这里的良好氛围,V2EX 有自己的明确规则: 7 |
8 |
• 这里绝对不讨论任何有关盗版软件、音乐、电影如何获得的问题 9 |
• 这里绝对不会全文转载任何文章,而只会以链接方式分享1 10 |
• 这里绝对不会有任何教人如何钻空子的讨论 11 |
• 这里感激和崇尚美的事物 12 |
• 这里尊重原创 13 |
• 这里反对中文互联网上的无信息量习惯如“顶”,“沙发”,“前排”,“留名”,“路过”,“不明觉厉” 14 |
• 这里禁止发布人身攻击、仇恨、暴力、侮辱性的言辞、暴露他人隐私的“人肉贴” 15 |
• 遵守中国的法律 16 | 17 | -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "size" : "20x20", 5 | "idiom" : "iphone", 6 | "filename" : "Icon-App-20x20@2x.png", 7 | "scale" : "2x" 8 | }, 9 | { 10 | "size" : "20x20", 11 | "idiom" : "iphone", 12 | "filename" : "Icon-App-20x20@3x.png", 13 | "scale" : "3x" 14 | }, 15 | { 16 | "size" : "29x29", 17 | "idiom" : "iphone", 18 | "filename" : "Icon-App-29x29@2x.png", 19 | "scale" : "2x" 20 | }, 21 | { 22 | "size" : "29x29", 23 | "idiom" : "iphone", 24 | "filename" : "Icon-App-29x29@3x.png", 25 | "scale" : "3x" 26 | }, 27 | { 28 | "size" : "40x40", 29 | "idiom" : "iphone", 30 | "filename" : "Icon-App-40x40@2x.png", 31 | "scale" : "2x" 32 | }, 33 | { 34 | "size" : "40x40", 35 | "idiom" : "iphone", 36 | "filename" : "Icon-App-40x40@3x.png", 37 | "scale" : "3x" 38 | }, 39 | { 40 | "size" : "60x60", 41 | "idiom" : "iphone", 42 | "filename" : "Icon-App-60x60@2x.png", 43 | "scale" : "2x" 44 | }, 45 | { 46 | "size" : "60x60", 47 | "idiom" : "iphone", 48 | "filename" : "Icon-App-60x60@3x.png", 49 | "scale" : "3x" 50 | }, 51 | { 52 | "size" : "20x20", 53 | "idiom" : "ipad", 54 | "filename" : "Icon-App-20x20@1x.png", 55 | "scale" : "1x" 56 | }, 57 | { 58 | "size" : "20x20", 59 | "idiom" : "ipad", 60 | "filename" : "Icon-App-20x20@2x-1.png", 61 | "scale" : "2x" 62 | }, 63 | { 64 | "size" : "29x29", 65 | "idiom" : "ipad", 66 | "filename" : "Icon-App-29x29@1x.png", 67 | "scale" : "1x" 68 | }, 69 | { 70 | "size" : "29x29", 71 | "idiom" : "ipad", 72 | "filename" : "Icon-App-29x29@2x-1.png", 73 | "scale" : "2x" 74 | }, 75 | { 76 | "size" : "40x40", 77 | "idiom" : "ipad", 78 | "filename" : "Icon-App-40x40@1x.png", 79 | "scale" : "1x" 80 | }, 81 | { 82 | "size" : "40x40", 83 | "idiom" : "ipad", 84 | "filename" : "Icon-App-40x40@2x-1.png", 85 | "scale" : "2x" 86 | }, 87 | { 88 | "size" : "76x76", 89 | "idiom" : "ipad", 90 | "filename" : "Icon-App-76x76@1x.png", 91 | "scale" : "1x" 92 | }, 93 | { 94 | "size" : "76x76", 95 | "idiom" : "ipad", 96 | "filename" : "Icon-App-76x76@2x.png", 97 | "scale" : "2x" 98 | }, 99 | { 100 | "size" : "83.5x83.5", 101 | "idiom" : "ipad", 102 | "filename" : "Icon-App-83.5x83.5@2x.png", 103 | "scale" : "2x" 104 | }, 105 | { 106 | "size" : "1024x1024", 107 | "idiom" : "ios-marketing", 108 | "filename" : "itunes.png", 109 | "scale" : "1x" 110 | } 111 | ], 112 | "info" : { 113 | "version" : 1, 114 | "author" : "xcode" 115 | } 116 | } -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkerk/v2ex/4d7240cfabe83ce9aa5cd5d9193543b3dca0af44/V2EX/Supporting Files/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkerk/v2ex/4d7240cfabe83ce9aa5cd5d9193543b3dca0af44/V2EX/Supporting Files/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x-1.png -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkerk/v2ex/4d7240cfabe83ce9aa5cd5d9193543b3dca0af44/V2EX/Supporting Files/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkerk/v2ex/4d7240cfabe83ce9aa5cd5d9193543b3dca0af44/V2EX/Supporting Files/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkerk/v2ex/4d7240cfabe83ce9aa5cd5d9193543b3dca0af44/V2EX/Supporting Files/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkerk/v2ex/4d7240cfabe83ce9aa5cd5d9193543b3dca0af44/V2EX/Supporting Files/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x-1.png -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkerk/v2ex/4d7240cfabe83ce9aa5cd5d9193543b3dca0af44/V2EX/Supporting Files/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkerk/v2ex/4d7240cfabe83ce9aa5cd5d9193543b3dca0af44/V2EX/Supporting Files/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkerk/v2ex/4d7240cfabe83ce9aa5cd5d9193543b3dca0af44/V2EX/Supporting Files/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkerk/v2ex/4d7240cfabe83ce9aa5cd5d9193543b3dca0af44/V2EX/Supporting Files/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x-1.png -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkerk/v2ex/4d7240cfabe83ce9aa5cd5d9193543b3dca0af44/V2EX/Supporting Files/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkerk/v2ex/4d7240cfabe83ce9aa5cd5d9193543b3dca0af44/V2EX/Supporting Files/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkerk/v2ex/4d7240cfabe83ce9aa5cd5d9193543b3dca0af44/V2EX/Supporting Files/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkerk/v2ex/4d7240cfabe83ce9aa5cd5d9193543b3dca0af44/V2EX/Supporting Files/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkerk/v2ex/4d7240cfabe83ce9aa5cd5d9193543b3dca0af44/V2EX/Supporting Files/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkerk/v2ex/4d7240cfabe83ce9aa5cd5d9193543b3dca0af44/V2EX/Supporting Files/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkerk/v2ex/4d7240cfabe83ce9aa5cd5d9193543b3dca0af44/V2EX/Supporting Files/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/AppIcon.appiconset/itunes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkerk/v2ex/4d7240cfabe83ce9aa5cd5d9193543b3dca0af44/V2EX/Supporting Files/Assets.xcassets/AppIcon.appiconset/itunes.png -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/Images/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/Images/avatar_default.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "avatar_default.pdf" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | } 12 | } -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/Images/avatar_default.imageset/avatar_default.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkerk/v2ex/4d7240cfabe83ce9aa5cd5d9193543b3dca0af44/V2EX/Supporting Files/Assets.xcassets/Images/avatar_default.imageset/avatar_default.pdf -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/Images/cancel_X.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "filename" : "cancel_X@2x.png", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "filename" : "cancel_X@3x.png", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "version" : 1, 20 | "author" : "xcode" 21 | } 22 | } -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/Images/cancel_X.imageset/cancel_X@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkerk/v2ex/4d7240cfabe83ce9aa5cd5d9193543b3dca0af44/V2EX/Supporting Files/Assets.xcassets/Images/cancel_X.imageset/cancel_X@2x.png -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/Images/cancel_X.imageset/cancel_X@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkerk/v2ex/4d7240cfabe83ce9aa5cd5d9193543b3dca0af44/V2EX/Supporting Files/Assets.xcassets/Images/cancel_X.imageset/cancel_X@3x.png -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/Images/google_icon.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "filename" : "google_icon@2x.png", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "filename" : "google_icon@3x.png", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "version" : 1, 20 | "author" : "xcode" 21 | } 22 | } -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/Images/google_icon.imageset/google_icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkerk/v2ex/4d7240cfabe83ce9aa5cd5d9193543b3dca0af44/V2EX/Supporting Files/Assets.xcassets/Images/google_icon.imageset/google_icon@2x.png -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/Images/google_icon.imageset/google_icon@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkerk/v2ex/4d7240cfabe83ce9aa5cd5d9193543b3dca0af44/V2EX/Supporting Files/Assets.xcassets/Images/google_icon.imageset/google_icon@3x.png -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/Images/hud_progress.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "hud_progress.pdf" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | } 12 | } -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/Images/hud_progress.imageset/hud_progress.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkerk/v2ex/4d7240cfabe83ce9aa5cd5d9193543b3dca0af44/V2EX/Supporting Files/Assets.xcassets/Images/hud_progress.imageset/hud_progress.pdf -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/Images/line.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "line.pdf" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | } 12 | } -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/Images/line.imageset/line.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkerk/v2ex/4d7240cfabe83ce9aa5cd5d9193543b3dca0af44/V2EX/Supporting Files/Assets.xcassets/Images/line.imageset/line.pdf -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/Images/nav_back.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "filename" : "nav_back@2x.png", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "filename" : "nav_back@3x.png", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "version" : 1, 20 | "author" : "xcode" 21 | } 22 | } -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/Images/nav_back.imageset/nav_back@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkerk/v2ex/4d7240cfabe83ce9aa5cd5d9193543b3dca0af44/V2EX/Supporting Files/Assets.xcassets/Images/nav_back.imageset/nav_back@2x.png -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/Images/nav_back.imageset/nav_back@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkerk/v2ex/4d7240cfabe83ce9aa5cd5d9193543b3dca0af44/V2EX/Supporting Files/Assets.xcassets/Images/nav_back.imageset/nav_back@3x.png -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/Images/nav_drawer.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "filename" : "nav_drawer@2x.png", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "filename" : "nav_drawer@3x.png", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "version" : 1, 20 | "author" : "xcode" 21 | } 22 | } -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/Images/nav_drawer.imageset/nav_drawer@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkerk/v2ex/4d7240cfabe83ce9aa5cd5d9193543b3dca0af44/V2EX/Supporting Files/Assets.xcassets/Images/nav_drawer.imageset/nav_drawer@2x.png -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/Images/nav_drawer.imageset/nav_drawer@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkerk/v2ex/4d7240cfabe83ce9aa5cd5d9193543b3dca0af44/V2EX/Supporting Files/Assets.xcassets/Images/nav_drawer.imageset/nav_drawer@3x.png -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/Images/nav_more.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "filename" : "nav_more@2x.png", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "filename" : "nav_more@3x.png", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "version" : 1, 20 | "author" : "xcode" 21 | } 22 | } -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/Images/nav_more.imageset/nav_more@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkerk/v2ex/4d7240cfabe83ce9aa5cd5d9193543b3dca0af44/V2EX/Supporting Files/Assets.xcassets/Images/nav_more.imageset/nav_more@2x.png -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/Images/nav_more.imageset/nav_more@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkerk/v2ex/4d7240cfabe83ce9aa5cd5d9193543b3dca0af44/V2EX/Supporting Files/Assets.xcassets/Images/nav_more.imageset/nav_more@3x.png -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/Images/share_thumbnail.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "filename" : "share_thumbnail@2x.png", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "filename" : "share_thumbnail@3x.png", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "version" : 1, 20 | "author" : "xcode" 21 | } 22 | } -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/Images/share_thumbnail.imageset/share_thumbnail@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkerk/v2ex/4d7240cfabe83ce9aa5cd5d9193543b3dca0af44/V2EX/Supporting Files/Assets.xcassets/Images/share_thumbnail.imageset/share_thumbnail@2x.png -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/Images/share_thumbnail.imageset/share_thumbnail@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkerk/v2ex/4d7240cfabe83ce9aa5cd5d9193543b3dca0af44/V2EX/Supporting Files/Assets.xcassets/Images/share_thumbnail.imageset/share_thumbnail@3x.png -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/Images/slide_menu_favorite.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "filename" : "slide_menu_favorite@2x.png", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "filename" : "slide_menu_favorite@3x.png", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "version" : 1, 20 | "author" : "xcode" 21 | } 22 | } -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/Images/slide_menu_favorite.imageset/slide_menu_favorite@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkerk/v2ex/4d7240cfabe83ce9aa5cd5d9193543b3dca0af44/V2EX/Supporting Files/Assets.xcassets/Images/slide_menu_favorite.imageset/slide_menu_favorite@2x.png -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/Images/slide_menu_favorite.imageset/slide_menu_favorite@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkerk/v2ex/4d7240cfabe83ce9aa5cd5d9193543b3dca0af44/V2EX/Supporting Files/Assets.xcassets/Images/slide_menu_favorite.imageset/slide_menu_favorite@3x.png -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/Images/slide_menu_message.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "filename" : "slide_menu_message@2x.png", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "filename" : "slide_menu_message@3x.png", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "version" : 1, 20 | "author" : "xcode" 21 | } 22 | } -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/Images/slide_menu_message.imageset/slide_menu_message@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkerk/v2ex/4d7240cfabe83ce9aa5cd5d9193543b3dca0af44/V2EX/Supporting Files/Assets.xcassets/Images/slide_menu_message.imageset/slide_menu_message@2x.png -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/Images/slide_menu_message.imageset/slide_menu_message@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkerk/v2ex/4d7240cfabe83ce9aa5cd5d9193543b3dca0af44/V2EX/Supporting Files/Assets.xcassets/Images/slide_menu_message.imageset/slide_menu_message@3x.png -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/Images/slide_menu_setting.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "filename" : "slide_menu_setting@2x.png", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "filename" : "slide_menu_setting@3x.png", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "version" : 1, 20 | "author" : "xcode" 21 | } 22 | } -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/Images/slide_menu_setting.imageset/slide_menu_setting@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkerk/v2ex/4d7240cfabe83ce9aa5cd5d9193543b3dca0af44/V2EX/Supporting Files/Assets.xcassets/Images/slide_menu_setting.imageset/slide_menu_setting@2x.png -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/Images/slide_menu_setting.imageset/slide_menu_setting@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkerk/v2ex/4d7240cfabe83ce9aa5cd5d9193543b3dca0af44/V2EX/Supporting Files/Assets.xcassets/Images/slide_menu_setting.imageset/slide_menu_setting@3x.png -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/Images/slide_menu_topic.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "filename" : "slide_menu_topic@2x.png", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "filename" : "slide_menu_topic@3x.png", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "version" : 1, 20 | "author" : "xcode" 21 | } 22 | } -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/Images/slide_menu_topic.imageset/slide_menu_topic@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkerk/v2ex/4d7240cfabe83ce9aa5cd5d9193543b3dca0af44/V2EX/Supporting Files/Assets.xcassets/Images/slide_menu_topic.imageset/slide_menu_topic@2x.png -------------------------------------------------------------------------------- /V2EX/Supporting Files/Assets.xcassets/Images/slide_menu_topic.imageset/slide_menu_topic@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkerk/v2ex/4d7240cfabe83ce9aa5cd5d9193543b3dca0af44/V2EX/Supporting Files/Assets.xcassets/Images/slide_menu_topic.imageset/slide_menu_topic@3x.png -------------------------------------------------------------------------------- /V2EX/Supporting Files/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | zh_CN 7 | CFBundleDisplayName 8 | V2EX 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | APPL 19 | CFBundleShortVersionString 20 | 1.0.5 21 | CFBundleURLTypes 22 | 23 | 24 | CFBundleTypeRole 25 | Editor 26 | CFBundleURLName 27 | weixin 28 | CFBundleURLSchemes 29 | 30 | wx9e26f0dc06b3f030 31 | 32 | 33 | 34 | CFBundleVersion 35 | 88-dev 36 | Fabric 37 | 38 | APIKey 39 | 1f93a016591ae96727c4cc87fb5ab34e2e629a49 40 | Kits 41 | 42 | 43 | KitInfo 44 | 45 | KitName 46 | Crashlytics 47 | 48 | 49 | 50 | LSApplicationQueriesSchemes 51 | 52 | org-appextension-feature-password-management 53 | weixin 54 | 55 | LSRequiresIPhoneOS 56 | 57 | NSAppTransportSecurity 58 | 59 | NSAllowsArbitraryLoads 60 | 61 | 62 | NSCameraUsageDescription 63 | 是否允许使用你的相机? 64 | NSPhotoLibraryUsageDescription 65 | 是否允许访问你的媒体资料库? 66 | UILaunchStoryboardName 67 | LaunchScreen 68 | UIMainStoryboardFile 69 | Main 70 | UIRequiredDeviceCapabilities 71 | 72 | armv7 73 | 74 | UIRequiresFullScreen 75 | 76 | UIStatusBarStyle 77 | UIStatusBarStyleDefault 78 | UISupportedInterfaceOrientations 79 | 80 | UIInterfaceOrientationPortrait 81 | UIInterfaceOrientationLandscapeLeft 82 | UIInterfaceOrientationLandscapeRight 83 | 84 | UISupportedInterfaceOrientations~ipad 85 | 86 | UIInterfaceOrientationPortrait 87 | UIInterfaceOrientationPortraitUpsideDown 88 | UIInterfaceOrientationLandscapeLeft 89 | UIInterfaceOrientationLandscapeRight 90 | 91 | 92 | 93 | -------------------------------------------------------------------------------- /V2EX/Supporting Files/LICENSES.html: -------------------------------------------------------------------------------- 1 | 2 |

Alamofire

3 |

Copyright (c) 2014-2016 Alamofire Software Foundation (http://alamofire.org/)

4 |

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

5 |

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

6 |

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

7 |
8 |

RxSwift

9 |

The MIT License 10 | Copyright © 2015 Krunoslav Zaher 11 | All rights reserved.

12 |

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

13 |

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

14 |

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

15 |
16 |

Moya

17 |

The MIT License (MIT)

18 |

Copyright (c) 2017 Artsy, Ash Furrow

19 |

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

20 |

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

21 |

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 22 |

23 |
24 |

Kanna

25 |

The MIT License (MIT)

26 |

Copyright (c) 2014 - 2015 Atsushi Kiwaki (@tid)

27 |

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

28 |

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

29 |

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 30 |

31 |
32 |

Kingfisher

33 |

The MIT License (MIT)

34 |

Copyright (c) 2017 Wei Wang

35 |

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

36 |

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

37 |

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 38 |

39 |
40 |

PKHUD

41 |

The MIT License (MIT)

42 |

Copyright (c) 2014 Philip Kluz (Philip.Kluz@gmail.com)

43 |

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

44 |

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

45 |

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 46 |

47 |
48 |

SKPhotoBrowser

49 |

The MIT License (MIT)

50 |

Copyright (c) 2015 suzuki_keishi

51 |

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

52 |

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

53 |

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 54 |

55 | 56 | -------------------------------------------------------------------------------- /V2EX/Supporting Files/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /V2EX/Supporting Files/style.css: -------------------------------------------------------------------------------- 1 | h1 { 2 | font-size: 16px; 3 | font-weight: 500; 4 | line-height: 100%; 5 | margin: 5px 0px 20px 0px; 6 | padding: 0px; 7 | word-wrap: break-word; 8 | } 9 | 10 | h2 { 11 | font-size: 16px; 12 | font-weight: 500; 13 | line-height: 100%; 14 | margin: 20px 0px 20px 0px; 15 | padding: 0px 0px 8px 0px; 16 | border-bottom: 1px solid #separator#; 17 | } 18 | 19 | h3 { 20 | font-size: 16px; 21 | font-weight: 500; 22 | line-height: 100%; 23 | margin: 5px 0px 20px 0px; 24 | padding: 0px; 25 | } 26 | 27 | hr { 28 | border: none; 29 | height: 1px; 30 | color: #separator#; 31 | background-color: #separator#; 32 | margin-bottom: 1em; 33 | } 34 | 35 | pre { 36 | font-family: 'Consolas', 'Panic Sans', 'Consolas', 'DejaVu Sans Mono', 37 | 'Bitstream Vera Sans Mono', 'Menlo', 'Microsoft Yahei', monospace; 38 | font-size: 13px; 39 | letter-spacing: 0.015em; 40 | line-height: 120%; 41 | white-space: pre; 42 | overflow-x: auto; 43 | overflow-y: auto; 44 | background-color: #codePre#; 45 | padding: 5px; 46 | } 47 | 48 | pre a { 49 | color: inherit; 50 | text-decoration: underline; 51 | } 52 | 53 | code { 54 | font-family: 'Consolas', 'Panic Sans', 'DejaVu Sans Mono', 55 | 'Bitstream Vera Sans Mono', 'Menlo', 'Microsoft Yahei', monospace; 56 | } 57 | 58 | a:link, a:visited, a:hover, a:active { 59 | color: #hyperlink#; 60 | text-decoration: none; 61 | word-break: break-all; 62 | } 63 | 64 | ul { 65 | margin: 1em 0px 1em 1.5em; 66 | padding: 0px; 67 | } 68 | 69 | ul li, ol li { 70 | padding: 0px; 71 | margin: 0px; 72 | } 73 | 74 | ol { 75 | margin: 1em 0px 0em 2em; 76 | padding: 0px; 77 | } 78 | 79 | .gray { 80 | color: #999; 81 | } 82 | 83 | .fade { 84 | color: #ccc; 85 | } 86 | 87 | .bigger { 88 | font-size: 16px; 89 | } 90 | 91 | .small { 92 | font-size: 11px; 93 | } 94 | 95 | .content { 96 | min-width: 600px; 97 | max-width: 1100px; 98 | margin: 0px auto 0px auto; 99 | } 100 | 101 | .subtle { 102 | background-color: #subtle#; 103 | padding: 5px 10px 0px 10px; 104 | font-size: 12px; 105 | line-height: 120%; 106 | text-align: left; 107 | } 108 | 109 | .topic_content { 110 | font-size: 15px; 111 | line-height: 1.6; 112 | color: #topic_content#; 113 | word-wrap: break-word; 114 | } 115 | 116 | .embedded_image { 117 | max-width: 100%; 118 | image-orientation: from-image; 119 | } 120 | 121 | .markdown_body img { 122 | max-width: 100%; 123 | } 124 | 125 | .markdown_body table { 126 | padding: 0; border-collapse: collapse; } 127 | .markdown_body table tr { 128 | border-top: 1px solid #cccccc; 129 | background-color: white; 130 | margin: 0; 131 | padding: 0; } 132 | .markdown_body table tr:nth-child(2n) { 133 | background-color: #f8f8f8; } 134 | .markdown_body table tr th { 135 | font-weight: bold; 136 | border: 1px solid #cccccc; 137 | margin: 0; 138 | padding: 6px 12px; } 139 | .markdown_body table tr td { 140 | border: 1px solid #cccccc; 141 | margin: 0; 142 | padding: 6px 12px; } 143 | .markdown_body table tr th :first-child, .markdown_body table tr td :first-child { 144 | margin-top: 0; } 145 | .markdown_body table tr th :last-child, .markdown_body table tr td :last-child { 146 | margin-bottom: 0; } 147 | 148 | body { 149 | background-color: #background#; 150 | } 151 | 152 | img { 153 | max-width: 100%; 154 | } 155 | 156 | .imgly { 157 | max-width: 100%; 158 | } 159 | 160 | .markdown_body>p>img { 161 | max-width: 100%; 162 | } 163 | 164 | 165 | 166 | -------------------------------------------------------------------------------- /V2EX/ViewControllers/AboutLicensesViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AboutLicensesViewController.swift 3 | // V2EX 4 | // 5 | // Created by darker on 2017/3/22. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import WebKit 11 | import SafariServices 12 | 13 | class AboutLicensesViewController: UIViewController { 14 | enum ViewType { 15 | case about, licenses 16 | } 17 | 18 | var viewType: ViewType = .about 19 | 20 | override func viewDidLoad() { 21 | super.viewDidLoad() 22 | 23 | // Do any additional setup after loading the view. 24 | navigationItem.title = viewType == .about ? "关于V2EX" : "LICENSES" 25 | view.backgroundColor = AppStyle.shared.theme.cellBackgroundColor 26 | 27 | let webView = WKWebView() 28 | webView.backgroundColor = AppStyle.shared.theme.cellBackgroundColor 29 | webView.scrollView.backgroundColor = AppStyle.shared.theme.cellBackgroundColor 30 | webView.translatesAutoresizingMaskIntoConstraints = false 31 | webView.navigationDelegate = self 32 | view.addSubview(webView) 33 | 34 | webView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true 35 | webView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true 36 | webView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true 37 | webView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true 38 | 39 | if let path = Bundle.main.path(forResource: viewType == .about ? "ABOUT" : "LICENSES", ofType: "html") { 40 | do { 41 | let body = try String(contentsOfFile: path, encoding: .utf8) 42 | let head = "" 43 | let html = "\(head)\(body)" 44 | webView.loadHTMLString(html, baseURL: nil) 45 | } catch { 46 | 47 | } 48 | } 49 | } 50 | 51 | override func didReceiveMemoryWarning() { 52 | super.didReceiveMemoryWarning() 53 | // Dispose of any resources that can be recreated. 54 | } 55 | 56 | var cssText: String { 57 | return "body {" + 58 | "background-color: \(AppStyle.shared.theme.webBackgroundColorHex);" + 59 | "font-size: 15px;" + 60 | "color: \(AppStyle.shared.theme.webTopicTextColorHex); }" + 61 | "h1 {" + 62 | "font-size: 18px;" + 63 | "font-weight: 500;" + 64 | "line-height: 100%;" + 65 | "margin: 5px 0px 20px 0px;" + 66 | "padding: 0px;" + 67 | "word-wrap: break-word; }" + 68 | "a {" + 69 | "color: \(AppStyle.shared.theme.webLinkColorHex);" + 70 | "text-decoration: none;" + 71 | "word-break: break-all; }" + 72 | "hr {" + 73 | "border: none;" + 74 | "height: 1px;" + 75 | "background-color: \(AppStyle.shared.theme.webLineColorHex);" + 76 | "margin-bottom: 1em; }" 77 | } 78 | } 79 | 80 | extension AboutLicensesViewController: WKNavigationDelegate { 81 | func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { 82 | if let url = navigationAction.request.url { 83 | if url.absoluteString.hasPrefix("https://") { 84 | let safari = SFSafariViewController(url: url) 85 | present(safari, animated: true, completion: nil) 86 | decisionHandler(.cancel) 87 | }else { 88 | decisionHandler(.allow) 89 | } 90 | }else { 91 | decisionHandler(.cancel) 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /V2EX/ViewControllers/AllPostsViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AllPostsViewController.swift 3 | // V2EX 4 | // 5 | // Created by darker on 2017/3/15. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import RxSwift 11 | import RxCocoa 12 | 13 | class AllPostsViewController: UITableViewController { 14 | 15 | var type: AllPostsType = .topic 16 | var moreHref: String = "" 17 | 18 | fileprivate let disposeBag = DisposeBag() 19 | 20 | override func viewDidLoad() { 21 | super.viewDidLoad() 22 | navigationItem.backBarButtonItem = UIBarButtonItem(title: "", style: .plain, target: nil, action: nil) 23 | let titleText = type == .topic ? "全部主题" : "全部回复" 24 | navigationItem.title = titleText 25 | 26 | tableView.backgroundColor = AppStyle.shared.theme.tableBackgroundColor 27 | tableView.separatorColor = AppStyle.shared.theme.separatorColor 28 | tableView.rowHeight = UITableView.automaticDimension 29 | tableView.estimatedRowHeight = 90 30 | tableView.dataSource = nil 31 | 32 | let viewModel = AllPostsViewModel(href: moreHref, type: type) 33 | viewModel.totalCount.asObservable().map({titleText + "(\($0))"}).bind(to: navigationItem.rx.title).disposed(by: disposeBag) 34 | 35 | viewModel.items.asObservable().bind(to: tableView.rx.items) { (table, row, item) in 36 | switch item { 37 | case let .topicItem(topic): 38 | let cell: TimelineTopicViewCell = table.dequeueReusableCell() 39 | cell.topic = topic 40 | return cell 41 | case let .replyItem(reply): 42 | let cell: TimelineReplyViewCell = table.dequeueReusableCell() 43 | cell.reply = reply 44 | return cell 45 | } 46 | }.disposed(by: disposeBag) 47 | 48 | tableView.rx.modelSelected(SectionTimelineItem.self).subscribe(onNext: {[weak navigationController] item in 49 | guard let nav = navigationController else { return } 50 | switch item { 51 | case let .topicItem(topic): 52 | TopicDetailsViewController.show(from: nav, topic: topic) 53 | case let .replyItem(reply): 54 | if let topic = reply.topic { 55 | TopicDetailsViewController.show(from: nav, topic: topic) 56 | } 57 | } 58 | }).disposed(by: disposeBag) 59 | 60 | tableView.rx.itemSelected.subscribe(onNext: {[weak tableView] indexPath in 61 | tableView?.deselectRow(at: indexPath, animated: true) 62 | }).disposed(by: disposeBag) 63 | 64 | tableView.addInfiniteScrolling {[weak tableView] in 65 | viewModel.fetchMoreData(completion: { 66 | tableView?.infiniteScrollingView?.stopAnimating() 67 | }) 68 | } 69 | 70 | if AppStyle.shared.theme == .night { 71 | tableView.infiniteScrollingView?.activityIndicatorView.style = .white 72 | } 73 | 74 | viewModel.loadMoreEnabled.asObservable().bind(to: tableView.rx.showsInfiniteScrolling).disposed(by: disposeBag) 75 | } 76 | 77 | override func didReceiveMemoryWarning() { 78 | super.didReceiveMemoryWarning() 79 | // Dispose of any resources that can be recreated. 80 | } 81 | 82 | /* 83 | // MARK: - Navigation 84 | 85 | // In a storyboard-based application, you will often want to do a little preparation before navigation 86 | override func prepare(for segue: UIStoryboardSegue, sender: Any?) { 87 | // Get the new view controller using segue.destinationViewController. 88 | // Pass the selected object to the new view controller. 89 | } 90 | */ 91 | } 92 | -------------------------------------------------------------------------------- /V2EX/ViewControllers/CreateTopicViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CreateTopicViewController.swift 3 | // V2EX 4 | // 5 | // Created by darker on 2017/4/11. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import RxSwift 11 | import RxCocoa 12 | import Moya 13 | import PKHUD 14 | 15 | @objc protocol CreateTopicViewControllerDelegate: class { 16 | @objc optional func createTopicSuccess(viewcontroller: CreateTopicViewController) 17 | } 18 | 19 | class CreateTopicViewController: UIViewController { 20 | 21 | @IBOutlet weak var textField: UITextField! 22 | @IBOutlet weak var textView: PlaceHolderTextView! 23 | @IBOutlet weak var sendButton: UIButton! 24 | @IBOutlet weak var textViewBottom: NSLayoutConstraint! 25 | @IBOutlet weak var lineView: UIView! 26 | 27 | weak var delegate: CreateTopicViewControllerDelegate? 28 | var nodeHref: String = "" 29 | 30 | fileprivate let disposeBag = DisposeBag() 31 | 32 | deinit { 33 | NotificationCenter.default.removeObserver(self) 34 | } 35 | 36 | override func viewDidLoad() { 37 | super.viewDidLoad() 38 | 39 | view.backgroundColor = AppStyle.shared.theme.tableBackgroundColor 40 | textField.backgroundColor = AppStyle.shared.theme.tableBackgroundColor 41 | textField.attributedPlaceholder = NSAttributedString(string: "标题", attributes: [NSAttributedString.Key.font: UIFont.systemFont(ofSize: 15), NSAttributedString.Key.foregroundColor: AppStyle.shared.theme.textPlaceHolderColor]) 42 | 43 | textView.backgroundColor = AppStyle.shared.theme.tableBackgroundColor 44 | textView.placeHolderColor = AppStyle.shared.theme.textPlaceHolderColor 45 | if AppStyle.shared.theme == .night { 46 | textField.keyboardAppearance = .dark 47 | textView.keyboardAppearance = .dark 48 | } 49 | lineView.backgroundColor = AppStyle.shared.theme.separatorColor 50 | 51 | let titleValid = textField.rx.text.orEmpty.map({$0.isEmpty == false}).share(replay: 1) 52 | let contentValid = textView.rx.text.orEmpty.map({$0.isEmpty == false}).share(replay: 1) 53 | let allValid = Observable.combineLatest(titleValid, contentValid) { $0 && $1 }.share(replay: 1) 54 | allValid.bind(to: sendButton.rx.isEnabled).disposed(by: disposeBag) 55 | 56 | textField.rx.controlEvent(.editingDidEndOnExit).subscribe(onNext: {[weak textView] in 57 | textView?.becomeFirstResponder() 58 | }).disposed(by: disposeBag) 59 | 60 | NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillChangeFrame(_:)), name: UIResponder.keyboardWillChangeFrameNotification, object: nil) 61 | 62 | textField.becomeFirstResponder() 63 | } 64 | 65 | @IBAction func sendAction(_ sender: Any) { 66 | guard let titleText = textField.text, let content = textView.text else { 67 | return 68 | } 69 | view.endEditing(true) 70 | HUD.show() 71 | API.provider.request(.once).flatMap { response -> Observable in 72 | if let once = HTMLParser.shared.once(html: response.data) { 73 | return API.provider.request(.createTopic(nodeHref: self.nodeHref, title: titleText, content: content, once: once)) 74 | }else { 75 | return Observable.error(NetError.message(text: "获取once失败")) 76 | } 77 | }.share(replay: 1).subscribe(onNext: { response in 78 | HUD.showText("发布成功!") 79 | self.delegate?.createTopicSuccess?(viewcontroller: self) 80 | self.navigationController?.popViewController(animated: true) 81 | }, onError: {error in 82 | HUD.showText(error.message) 83 | }).disposed(by: disposeBag) 84 | 85 | } 86 | 87 | @objc func keyboardWillChangeFrame(_ notification: Notification) { 88 | guard let info = notification.userInfo as? [String: Any] else { 89 | return 90 | } 91 | 92 | let frameEnd = (info[UIResponder.keyboardFrameEndUserInfoKey] as! NSValue).cgRectValue 93 | let duration = (info[UIResponder.keyboardAnimationDurationUserInfoKey] as! NSNumber).doubleValue 94 | let curve = (info[UIResponder.keyboardAnimationCurveUserInfoKey] as! NSNumber).uintValue 95 | let options = UIView.AnimationOptions(rawValue: curve << 16) 96 | 97 | self.textViewBottom.constant = frameEnd.height 98 | 99 | UIView.animate(withDuration: duration, 100 | delay: 0, 101 | options: options, 102 | animations: { 103 | self.view.layoutIfNeeded() 104 | }, 105 | completion: nil) 106 | } 107 | 108 | override func didReceiveMemoryWarning() { 109 | super.didReceiveMemoryWarning() 110 | // Dispose of any resources that can be recreated. 111 | } 112 | } 113 | 114 | 115 | -------------------------------------------------------------------------------- /V2EX/ViewControllers/FavoriteViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FavoriteViewController.swift 3 | // V2EX 4 | // 5 | // Created by darker on 2017/3/17. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import RxSwift 11 | import RxCocoa 12 | import Kingfisher 13 | 14 | class FavoriteViewController: UITableViewController { 15 | 16 | @IBOutlet weak var segmentedControl: UISegmentedControl! 17 | 18 | lazy var viewModel = FavoriteViewModel() 19 | fileprivate let disposeBag = DisposeBag() 20 | 21 | override func viewDidLoad() { 22 | super.viewDidLoad() 23 | navigationItem.backBarButtonItem = UIBarButtonItem(title: "", style: .plain, target: nil, action: nil) 24 | 25 | tableView.backgroundColor = AppStyle.shared.theme.tableBackgroundColor 26 | tableView.separatorColor = AppStyle.shared.theme.separatorColor 27 | tableView.rowHeight = UITableView.automaticDimension 28 | tableView.estimatedRowHeight = 90 29 | tableView.dataSource = nil 30 | 31 | viewModel.dataItems.asObservable().bind(to: tableView.rx.items) {[weak self] (table, row, item) in 32 | switch item { 33 | case let .topicItem(topic), let .followingItem(topic): 34 | let cell: TopicViewCell = table.dequeueReusableCell() 35 | cell.topic = topic 36 | cell.linkTap = {type in 37 | self?.linkTapAction(type: type) 38 | } 39 | return cell 40 | case let .nodeItem(node): 41 | let cell: FavoriteNodeViewCell = table.dequeueReusableCell() 42 | cell.node = node 43 | return cell 44 | } 45 | }.disposed(by: disposeBag) 46 | 47 | tableView.rx.modelSelected(FavoriteItem.self).subscribe(onNext: {[weak self] item in 48 | switch item { 49 | case let .topicItem(topic), let .followingItem(topic): 50 | if let nav = self?.navigationController { 51 | TopicDetailsViewController.show(from: nav, topic: topic) 52 | } 53 | case let .nodeItem(node): 54 | self?.performSegue(withIdentifier: NodeTopicsViewController.segueId, sender: node) 55 | break 56 | } 57 | }).disposed(by: disposeBag) 58 | 59 | tableView.addInfiniteScrolling {[weak tableView, weak viewModel] in 60 | viewModel?.fetchMoreData(completion: { 61 | tableView?.infiniteScrollingView?.stopAnimating() 62 | }) 63 | } 64 | viewModel.loadMoreEnabled.asObservable().bind(to: tableView.rx.showsInfiniteScrolling).disposed(by: disposeBag) 65 | } 66 | 67 | @IBAction func segmentedChange(_ sender: UISegmentedControl) { 68 | viewModel.type = FavoriteDataType(rawValue: sender.selectedSegmentIndex)! 69 | } 70 | 71 | func linkTapAction(type: TapLink) { 72 | guard let nav = navigationController else { return } 73 | switch type { 74 | case let .user(info): 75 | TimelineViewController.show(from: nav, user: info) 76 | case let .node(info): 77 | NodeTopicsViewController.show(from: nav, node: info) 78 | default: break 79 | } 80 | } 81 | 82 | override func didReceiveMemoryWarning() { 83 | super.didReceiveMemoryWarning() 84 | // Dispose of any resources that can be recreated. 85 | } 86 | 87 | // MARK: - Navigation 88 | // In a storyboard-based application, you will often want to do a little preparation before navigation 89 | override func prepare(for segue: UIStoryboardSegue, sender: Any?) { 90 | if let node = sender as? Node, segue.destination is NodeTopicsViewController { 91 | let controller = segue.destination as! NodeTopicsViewController 92 | controller.nodeHref = node.href 93 | controller.title = node.name 94 | } 95 | } 96 | } 97 | 98 | extension FavoriteViewController { 99 | override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { 100 | tableView.deselectRow(at: indexPath, animated: true) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /V2EX/ViewControllers/HomeViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HomeViewController.swift 3 | // V2EX 4 | // 5 | // Created by darker on 2017/3/2. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Kanna 11 | import RxSwift 12 | import RxCocoa 13 | import RxDataSources 14 | 15 | class HomeViewController: UITableViewController { 16 | 17 | fileprivate lazy var viewModel = HomeViewModel() 18 | fileprivate var dataSource: RxTableViewSectionedAnimatedDataSource! 19 | private let disposeBag = DisposeBag() 20 | 21 | override func viewDidLoad() { 22 | super.viewDidLoad() 23 | navigationItem.backBarButtonItem = UIBarButtonItem(title: "", style: .plain, target: nil, action: nil) 24 | 25 | AppStyle.shared.themeUpdateVariable.asObservable().subscribe(onNext: { update in 26 | self.updateTheme() 27 | }).disposed(by: disposeBag) 28 | 29 | dataSource = RxTableViewSectionedAnimatedDataSource(configureCell: {[weak self] (ds, table, indexPath, _) in 30 | let cell: TopicViewCell = table.dequeueReusableCell() 31 | let item = ds[indexPath] 32 | cell.topic = item 33 | cell.linkTap = {type in 34 | self?.linkTapAction(type: type) 35 | } 36 | return cell 37 | }) 38 | 39 | tableView.rowHeight = UITableView.automaticDimension 40 | tableView.estimatedRowHeight = 90 41 | tableView.delegate = nil 42 | tableView.dataSource = nil 43 | 44 | refreshControl = UIRefreshControl() 45 | refreshControl?.rx.controlEvent(.valueChanged) 46 | .flatMapLatest({[unowned viewModel]_ in 47 | viewModel.fetchTopics()}) 48 | .share(replay: 1) 49 | .subscribe(onNext: {isEmpty in 50 | 51 | }, onError: {error in 52 | print("error: ", error) 53 | }).disposed(by: disposeBag) 54 | 55 | viewModel.loadingActivityIndicator.asObservable().bind(to: refreshControl!.rx.isRefreshing).disposed(by: disposeBag) 56 | viewModel.sections.asObservable().bind(to: tableView.rx.items(dataSource: dataSource!)).disposed(by: disposeBag) 57 | viewModel.defaultNodes.asObservable().subscribe(onNext: {[weak self] nodes in 58 | guard let `self` = self else { return } 59 | if let currentNode = nodes.filter({$0.isCurrent}).first { 60 | self.navigationItem.rightBarButtonItem?.title = currentNode.name 61 | }else { 62 | self.navigationItem.rightBarButtonItem?.title = nil 63 | } 64 | }).disposed(by: disposeBag) 65 | 66 | tableView.rx.itemSelected.subscribe(onNext: {[weak self] indexPath in 67 | guard let strongSelf = self else { return } 68 | strongSelf.tableView.deselectRow(at: indexPath, animated: true) 69 | }).disposed(by: disposeBag) 70 | 71 | refreshControl?.sendActions(for: .valueChanged) 72 | tableView.setContentOffset(CGPoint(x: 0, y: -60), animated: true) 73 | } 74 | 75 | @IBAction func leftBarItemAction(_ sender: Any) { 76 | self.presentationController?.presentedViewController.dismiss(animated: true, completion: nil) 77 | 78 | guard let drawerViewController = drawerViewController else { return } 79 | drawerViewController.isOpenDrawer = !drawerViewController.isOpenDrawer 80 | } 81 | 82 | func linkTapAction(type: TapLink) { 83 | guard let nav = navigationController else { return } 84 | switch type { 85 | case let .user(info): 86 | TimelineViewController.show(from: nav, user: info) 87 | case let .node(info): 88 | NodeTopicsViewController.show(from: nav, node: info) 89 | default: break 90 | } 91 | } 92 | 93 | override func didReceiveMemoryWarning() { 94 | super.didReceiveMemoryWarning() 95 | // Dispose of any resources that can be recreated. 96 | } 97 | 98 | // MARK: - Navigation 99 | 100 | // In a storyboard-based application, you will often want to do a little preparation before navigation 101 | override func prepare(for segue: UIStoryboardSegue, sender: Any?) { 102 | switch segue.destination { 103 | case is NodesViewController: 104 | 105 | let controller = segue.destination as! NodesViewController 106 | controller.popoverPresentationController?.delegate = self 107 | controller.nodeItems = viewModel.defaultNodes.value 108 | controller.nodesNavigation = viewModel.nodesNavigation 109 | controller.selectedItem.asObservable().subscribe(onNext: {[weak self] node in 110 | if let node = node { 111 | guard let `self` = self else { 112 | return 113 | } 114 | if self.navigationItem.rightBarButtonItem?.title != node.name { 115 | self.navigationItem.rightBarButtonItem?.title = node.name 116 | let defaultNodes = self.viewModel.defaultNodes.value.map({item -> Node in 117 | var newNode = item 118 | newNode.isCurrent = (item.name == node.name) 119 | return newNode 120 | }) 121 | self.viewModel.defaultNodes.value = defaultNodes 122 | self.viewModel.nodeHref = node.href 123 | self.viewModel.sections.value.removeAll() 124 | self.refreshControl?.sendActions(for: .valueChanged) 125 | } 126 | } 127 | }).disposed(by: disposeBag) 128 | case is TopicDetailsViewController: 129 | guard let cell = sender as? TopicViewCell, let indexPath = tableView.indexPath(for: cell) else { 130 | return 131 | } 132 | let topic = dataSource[indexPath] 133 | let controller = segue.destination as! TopicDetailsViewController 134 | controller.delegate = self 135 | controller.viewModel = TopicDetailsViewModel(topic: topic) 136 | default: 137 | break 138 | } 139 | } 140 | } 141 | 142 | extension HomeViewController: TopicDetailsViewControllerDelegate { 143 | func topicDetailsViewController(viewcontroller: TopicDetailsViewController, ignoreTopic topicId: String?) { 144 | if let topicId = topicId { 145 | viewModel.removeTopic(for: topicId) 146 | } 147 | } 148 | } 149 | 150 | extension HomeViewController: UIPopoverPresentationControllerDelegate { 151 | func adaptivePresentationStyle(for controller: UIPresentationController) -> UIModalPresentationStyle { 152 | return .none 153 | } 154 | 155 | func prepareForPopoverPresentation(_ popoverPresentationController: UIPopoverPresentationController) { 156 | popoverPresentationController.backgroundColor = AppStyle.shared.theme.tableBackgroundColor 157 | } 158 | } 159 | 160 | -------------------------------------------------------------------------------- /V2EX/ViewControllers/MessageViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MessageViewController.swift 3 | // V2EX 4 | // 5 | // Created by darker on 2017/3/17. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import RxSwift 11 | import RxCocoa 12 | 13 | class MessageViewController: UITableViewController { 14 | 15 | fileprivate let disposeBag = DisposeBag() 16 | 17 | override func viewDidLoad() { 18 | super.viewDidLoad() 19 | navigationItem.backBarButtonItem = UIBarButtonItem(title: "", style: .plain, target: nil, action: nil) 20 | 21 | tableView.backgroundColor = AppStyle.shared.theme.tableBackgroundColor 22 | tableView.separatorColor = AppStyle.shared.theme.separatorColor 23 | tableView.rowHeight = UITableView.automaticDimension 24 | tableView.estimatedRowHeight = 90 25 | tableView.dataSource = nil 26 | 27 | let viewModel = MessageViewModel() 28 | viewModel.items.asObservable().bind(to: tableView.rx.items) { (table, row, item) in 29 | let cell: MessageViewCell = table.dequeueReusableCell() 30 | cell.message = item 31 | return cell 32 | }.disposed(by: disposeBag) 33 | 34 | tableView.addInfiniteScrolling {[weak tableView] in 35 | viewModel.fetchMoreData(completion: { 36 | tableView?.infiniteScrollingView?.stopAnimating() 37 | }) 38 | } 39 | 40 | if AppStyle.shared.theme == .night { 41 | tableView.infiniteScrollingView?.activityIndicatorView.style = .white 42 | } 43 | 44 | viewModel.loadMoreEnabled.asObservable().bind(to: tableView.rx.showsInfiniteScrolling).disposed(by: disposeBag) 45 | 46 | tableView.rx.modelSelected(Message.self).subscribe(onNext: {[weak navigationController] item in 47 | if let nav = navigationController, let topic = item.topic { 48 | TopicDetailsViewController.show(from: nav, topic: topic) 49 | } 50 | }).disposed(by: disposeBag) 51 | } 52 | 53 | override func didReceiveMemoryWarning() { 54 | super.didReceiveMemoryWarning() 55 | // Dispose of any resources that can be recreated. 56 | } 57 | } 58 | 59 | extension MessageViewController { 60 | override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { 61 | tableView.deselectRow(at: indexPath, animated: true) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /V2EX/ViewControllers/NodeNavigationViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NodeNavigationViewController.swift 3 | // V2EX 4 | // 5 | // Created by darker on 2017/4/6. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class NodeNavigationViewController: UITableViewController { 12 | 13 | var navigationItems: [(name: String, content: String)] = [] 14 | 15 | class func show(from navigationController: UINavigationController, items: [(name: String, content: String)]) { 16 | let controller = UIStoryboard(name: "Home", bundle: nil).instantiateViewController(withIdentifier: NodeNavigationViewController.segueId) as! NodeNavigationViewController 17 | controller.navigationItems = items 18 | navigationController.pushViewController(controller, animated: true) 19 | } 20 | 21 | override func viewDidLoad() { 22 | super.viewDidLoad() 23 | navigationItem.title = "节点导航" 24 | navigationItem.backBarButtonItem = UIBarButtonItem(title: "", style: .plain, target: nil, action: nil) 25 | 26 | tableView.backgroundColor = AppStyle.shared.theme.tableGroupBackgroundColor 27 | tableView.separatorColor = AppStyle.shared.theme.separatorColor 28 | tableView.rowHeight = UITableView.automaticDimension 29 | tableView.estimatedRowHeight = 90 30 | } 31 | 32 | override func didReceiveMemoryWarning() { 33 | super.didReceiveMemoryWarning() 34 | // Dispose of any resources that can be recreated. 35 | } 36 | } 37 | 38 | extension NodeNavigationViewController { 39 | // MARK: - Table view data source 40 | override func numberOfSections(in tableView: UITableView) -> Int { 41 | return navigationItems.count 42 | } 43 | 44 | override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 45 | return 1 46 | } 47 | 48 | override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { 49 | return navigationItems[section].name 50 | } 51 | 52 | override func tableView(_ tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int) { 53 | if view is UITableViewHeaderFooterView { 54 | let header = view as! UITableViewHeaderFooterView 55 | header.textLabel?.textColor = AppStyle.shared.theme.black153Color 56 | } 57 | } 58 | 59 | override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 60 | let cell: NodeNavigationViewCell = tableView.dequeueReusableCell(for: indexPath) 61 | cell.content = navigationItems[indexPath.section].content 62 | cell.linkTap = {type in 63 | switch type { 64 | case let .node(info): 65 | guard let nav = self.navigationController else { return } 66 | NodeTopicsViewController.show(from: nav, node: info) 67 | default: 68 | break 69 | } 70 | } 71 | return cell 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /V2EX/ViewControllers/NodeTopicsViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NodeTopicsViewController.swift 3 | // V2EX 4 | // 5 | // Created by darker on 2017/3/20. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import RxSwift 11 | import RxCocoa 12 | import PKHUD 13 | 14 | class NodeTopicsViewController: UITableViewController { 15 | 16 | let viewModel = NodeTopicsViewModel() 17 | var nodeHref: String = "" 18 | 19 | fileprivate let disposeBag = DisposeBag() 20 | 21 | class func show(from navigationController: UINavigationController, node: Node) { 22 | let controller = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: NodeTopicsViewController.segueId) as! NodeTopicsViewController 23 | 24 | controller.nodeHref = node.href 25 | controller.title = node.name 26 | navigationController.pushViewController(controller, animated: true) 27 | } 28 | 29 | override func viewDidLoad() { 30 | super.viewDidLoad() 31 | navigationItem.backBarButtonItem = UIBarButtonItem(title: "", style: .plain, target: nil, action: nil) 32 | 33 | tableView.backgroundColor = AppStyle.shared.theme.tableBackgroundColor 34 | tableView.separatorColor = AppStyle.shared.theme.separatorColor 35 | tableView.rowHeight = UITableView.automaticDimension 36 | tableView.estimatedRowHeight = 90 37 | tableView.dataSource = nil 38 | 39 | viewModel.nodeHref = nodeHref 40 | viewModel.fetcData() 41 | 42 | viewModel.items.asObservable().bind(to: tableView.rx.items) {[weak navigationController] (table, row, item) in 43 | let cell: NodeTopicsViewCell = table.dequeueReusableCell() 44 | cell.topic = item 45 | if let nav = navigationController { 46 | cell.avatarTap = { 47 | TimelineViewController.show(from: nav, user: item.owner) 48 | } 49 | } 50 | return cell 51 | }.disposed(by: disposeBag) 52 | 53 | tableView.addInfiniteScrolling {[weak viewModel] in 54 | viewModel?.fetchMoreData() 55 | } 56 | 57 | if AppStyle.shared.theme == .night { 58 | tableView.infiniteScrollingView?.activityIndicatorView.style = .white 59 | } 60 | 61 | viewModel.loadMoreEnabled.asObservable().bind(to: tableView.rx.showsInfiniteScrolling).disposed(by: disposeBag) 62 | viewModel.loadMoreCompleted.asObservable().subscribe(onNext: {[weak tableView] isFinished in 63 | if isFinished { 64 | tableView?.infiniteScrollingView?.stopAnimating() 65 | } 66 | }).disposed(by: disposeBag) 67 | 68 | viewModel.loadingActivityIndicator.asObservable().subscribe(onNext: {[weak self] isLoading in 69 | guard let `self` = self else { return } 70 | if isLoading { 71 | let activityIndicator = UIActivityIndicatorView(style: AppStyle.shared.theme.activityIndicatorStyle) 72 | activityIndicator.startAnimating() 73 | self.navigationItem.rightBarButtonItem = UIBarButtonItem(customView: activityIndicator) 74 | }else { 75 | self.navigationItem.rightBarButtonItem = UIBarButtonItem(image: #imageLiteral(resourceName: "nav_more"), style: .plain, target: self, action: #selector(self.moreAction(_:))) 76 | } 77 | }).disposed(by: disposeBag) 78 | 79 | var shouldLogin = false 80 | viewModel.shouldLogin.asObservable().subscribe(onNext: {[weak self] should in 81 | if should { 82 | shouldLogin = true 83 | self?.showLoginAlert(isPopBack: true) 84 | } 85 | }).disposed(by: disposeBag) 86 | 87 | Account.shared.isLoggedIn.asObservable().subscribe(onNext: {isLoggedIn in 88 | if isLoggedIn && shouldLogin { 89 | self.viewModel.fetcData() 90 | } 91 | }).disposed(by: disposeBag) 92 | } 93 | 94 | @objc func moreAction(_ sender: Any) { 95 | let alert = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) 96 | alert.addAction(UIAlertAction(title: "取消", style: .cancel, handler: nil)) 97 | let isFavorite = viewModel.isFavorited 98 | let favoriteAction = UIAlertAction(title: isFavorite ? "取消收藏" : "收藏", style: .default, handler: {action in 99 | self.viewModel.sendFavorite(completion: {isSuccess in 100 | if isSuccess { 101 | HUD.showText(isFavorite ? "取消收藏成功!" : "收藏成功!") 102 | }else { 103 | HUD.showText(isFavorite ? "取消收藏失败!" : "收藏失败!") 104 | } 105 | }) 106 | }) 107 | 108 | alert.addAction(favoriteAction) 109 | alert.addAction(UIAlertAction(title: "创建新主题", style: .default, handler: {_ in 110 | self.performSegue(withIdentifier: CreateTopicViewController.segueId, sender: nil) 111 | })) 112 | if UI_USER_INTERFACE_IDIOM() == .pad { 113 | alert.popoverPresentationController?.barButtonItem = navigationItem.rightBarButtonItem 114 | } 115 | present(alert, animated: true, completion: nil) 116 | } 117 | 118 | override func didReceiveMemoryWarning() { 119 | super.didReceiveMemoryWarning() 120 | // Dispose of any resources that can be recreated. 121 | } 122 | 123 | // MARK: - Navigation 124 | // In a storyboard-based application, you will often want to do a little preparation before navigation 125 | override func prepare(for segue: UIStoryboardSegue, sender: Any?) { 126 | if segue.destination is CreateTopicViewController { 127 | let controller = segue.destination as! CreateTopicViewController 128 | controller.nodeHref = nodeHref 129 | controller.delegate = self 130 | } 131 | } 132 | } 133 | 134 | extension NodeTopicsViewController: CreateTopicViewControllerDelegate { 135 | func createTopicSuccess(viewcontroller: CreateTopicViewController) { 136 | viewModel.fetcData() 137 | } 138 | } 139 | 140 | extension NodeTopicsViewController { 141 | override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { 142 | tableView.deselectRow(at: indexPath, animated: true) 143 | let item = viewModel.items.value[indexPath.row] 144 | if let nav = navigationController { 145 | TopicDetailsViewController.show(from: nav, topic: item) 146 | } 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /V2EX/ViewControllers/NodesViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NodesViewController.swift 3 | // V2EX 4 | // 5 | // Created by darker on 2017/3/3. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import RxSwift 11 | import RxCocoa 12 | 13 | class NodesViewController: UITableViewController { 14 | 15 | var nodeItems: [Node] = [] 16 | var nodesNavigation: [(name: String, content: String)] = [] 17 | let selectedItem = Variable(nil) 18 | 19 | private let disposeBag = DisposeBag() 20 | 21 | override func viewDidLoad() { 22 | super.viewDidLoad() 23 | 24 | tableView.backgroundColor = AppStyle.shared.theme.tableBackgroundColor 25 | tableView.separatorColor = AppStyle.shared.theme.separatorColor 26 | } 27 | 28 | override func didReceiveMemoryWarning() { 29 | super.didReceiveMemoryWarning() 30 | // Dispose of any resources that can be recreated. 31 | } 32 | } 33 | 34 | extension NodesViewController { 35 | // MARK: - Table view data source 36 | override func numberOfSections(in tableView: UITableView) -> Int { 37 | return 2 38 | } 39 | 40 | override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 41 | return section == 0 ? nodeItems.count : 1 42 | } 43 | 44 | override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 45 | let cell: NodeListViewCell = tableView.dequeueReusableCell(for: indexPath) 46 | if indexPath.section == 0 { 47 | cell.node = nodeItems[indexPath.row] 48 | }else { 49 | cell.textLabel?.text = "更多节点" 50 | switch AppStyle.shared.theme { 51 | case .normal: 52 | cell.textLabel?.textColor = #colorLiteral(red: 0.3137254902, green: 0.3137254902, blue: 0.3137254902, alpha: 1) 53 | case .night: 54 | cell.textLabel?.textColor = #colorLiteral(red: 0.6078431373, green: 0.6862745098, blue: 0.8, alpha: 1) 55 | } 56 | } 57 | return cell 58 | } 59 | 60 | override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { 61 | tableView.deselectRow(at: indexPath, animated: true) 62 | 63 | if indexPath.section == 0 { 64 | self.selectedItem.value = nodeItems[indexPath.row] 65 | self.dismiss(animated: true, completion: nil) 66 | }else { 67 | if let navigationController = drawerViewController?.centerViewController as? UINavigationController { 68 | dismiss(animated: true, completion: nil) 69 | NodeNavigationViewController.show(from: navigationController, items: nodesNavigation) 70 | } 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /V2EX/ViewControllers/ProfileViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProfileViewController.swift 3 | // V2EX 4 | // 5 | // Created by darker on 2017/3/14. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import RxSwift 11 | import RxCocoa 12 | import Moya 13 | import PKHUD 14 | 15 | class ProfileViewController: UITableViewController { 16 | 17 | @IBOutlet weak var headerView: ProfileHeaderView! 18 | lazy var menuItems = [(#imageLiteral(resourceName: "slide_menu_topic"), "个人"), 19 | (#imageLiteral(resourceName: "slide_menu_message"), "消息"), 20 | (#imageLiteral(resourceName: "slide_menu_favorite"), "收藏"), 21 | (#imageLiteral(resourceName: "slide_menu_setting"), "设置")] 22 | 23 | private let disposeBag = DisposeBag() 24 | 25 | var navController: UINavigationController? { 26 | return drawerViewController?.centerViewController as? UINavigationController 27 | } 28 | 29 | override func viewDidLoad() { 30 | super.viewDidLoad() 31 | 32 | AppStyle.shared.themeUpdateVariable.asObservable().subscribe(onNext: { update in 33 | self.updateTheme() 34 | if update { 35 | self.headerView.updateTheme() 36 | self.tableView.reloadData() 37 | } 38 | }).disposed(by: disposeBag) 39 | 40 | tableView.delegate = nil 41 | tableView.dataSource = nil 42 | 43 | Account.shared.user.asObservable().bind(to: headerView.rx.user).disposed(by: disposeBag) 44 | Account.shared.isLoggedIn.asObservable().subscribe(onNext: {isLoggedIn in 45 | if !isLoggedIn { 46 | self.headerView.logout() 47 | } 48 | }).disposed(by: disposeBag) 49 | 50 | Observable.just(menuItems).bind(to: tableView.rx.items) { (tableView, row, item) in 51 | let cell: ProfileMenuViewCell = tableView.dequeueReusableCell() 52 | cell.updateTheme() 53 | cell.configure(image: item.0, text: item.1) 54 | return cell 55 | }.disposed(by: disposeBag) 56 | 57 | tableView.rx.itemSelected.subscribe(onNext: {[weak self] indexPath in 58 | guard let `self` = self else { return } 59 | self.tableView.deselectRow(at: indexPath, animated: true) 60 | guard let nav = self.navController else { 61 | return 62 | } 63 | guard Account.shared.isLoggedIn.value else { 64 | self.showLoginView() 65 | return 66 | } 67 | switch indexPath.row { 68 | case 0: 69 | self.drawerViewController?.isOpenDrawer = false 70 | TimelineViewController.show(from: nav, user: Account.shared.user.value) 71 | case 1: 72 | if Account.shared.unreadCount.value > 0 { 73 | Account.shared.unreadCount.value = 0 74 | } 75 | self.drawerViewController?.isOpenDrawer = false 76 | nav.performSegue(withIdentifier: MessageViewController.segueId, sender: nil) 77 | case 2: 78 | self.drawerViewController?.isOpenDrawer = false 79 | nav.performSegue(withIdentifier: FavoriteViewController.segueId, sender: nil) 80 | case 3: 81 | self.drawerViewController?.isOpenDrawer = false 82 | nav.performSegue(withIdentifier: SettingViewController.segueId, sender: nil) 83 | default: break 84 | } 85 | 86 | }).disposed(by: disposeBag) 87 | 88 | if let cell = tableView.cellForRow(at: IndexPath(item: 1, section: 0)) as? ProfileMenuViewCell { 89 | Account.shared.unreadCount.asObservable().bind(to: cell.rx.unread).disposed(by: disposeBag) 90 | } 91 | 92 | Account.shared.isDailyRewards.asObservable().flatMapLatest { canRedeem -> Observable in 93 | if canRedeem { 94 | return Account.shared.redeemDailyRewards() 95 | } 96 | return Observable.just(false) 97 | }.share(replay: 1).delay(1, scheduler: MainScheduler.instance).subscribe(onNext: { success in 98 | if success { 99 | HUD.showText("已领取每日登录奖励!") 100 | Account.shared.isDailyRewards.value = false 101 | } 102 | }, onError: { error in 103 | print(error.message) 104 | }).disposed(by: disposeBag) 105 | 106 | } 107 | 108 | @IBAction func loginButtonAction(_ sender: Any) { 109 | if let nav = navController, Account.shared.isLoggedIn.value { 110 | drawerViewController?.isOpenDrawer = false 111 | TimelineViewController.show(from: nav, user: Account.shared.user.value) 112 | }else { 113 | showLoginView() 114 | } 115 | } 116 | 117 | func showLoginView() { 118 | drawerViewController?.performSegue(withIdentifier: LoginViewController.segueId, sender: nil) 119 | } 120 | 121 | override func didReceiveMemoryWarning() { 122 | super.didReceiveMemoryWarning() 123 | // Dispose of any resources that can be recreated. 124 | } 125 | } 126 | 127 | 128 | -------------------------------------------------------------------------------- /V2EX/ViewModel/AllPostsViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AllPostsViewModel.swift 3 | // V2EX 4 | // 5 | // Created by darker on 2017/3/15. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import RxSwift 10 | import Moya 11 | 12 | enum AllPostsType { 13 | case topic, reply 14 | } 15 | 16 | class AllPostsViewModel { 17 | let items = Variable<[SectionTimelineItem]>([]) 18 | let totalCount = Variable("0") 19 | let loadMoreEnabled = Variable(false) 20 | 21 | private var currentPage = 1 22 | private var totalPage = 1 23 | private var href: String = "" 24 | private var type: AllPostsType = .topic 25 | 26 | private let disposeBag = DisposeBag() 27 | 28 | init(href: String, type: AllPostsType) { 29 | self.href = href 30 | self.type = type 31 | fetcData() 32 | } 33 | 34 | func fetcData(page: Int = 1, completion: (() -> Void)? = nil) { 35 | let api = API.pageList(href: href, page: page) 36 | API.provider.request(api).subscribe(onNext: { response in 37 | switch self.type { 38 | case .topic: 39 | if let data = HTMLParser.shared.profileAllTopics(html: response.data) { 40 | self.currentPage = data.currentPage 41 | self.totalPage = data.totalPage 42 | self.totalCount.value = data.totalCount 43 | let topics = data.topics.flatMap({[SectionTimelineItem.topicItem(topic: $0)]}) 44 | self.items.value.append(contentsOf: topics) 45 | 46 | self.loadMoreEnabled.value = data.totalPage > data.currentPage 47 | } 48 | case .reply: 49 | if let data = HTMLParser.shared.profileAllReplies(html: response.data) { 50 | self.currentPage = data.currentPage 51 | self.totalPage = data.totalPage 52 | self.totalCount.value = data.totalCount 53 | let replies = data.replies.flatMap({[SectionTimelineItem.replyItem(reply: $0)]}) 54 | self.items.value.append(contentsOf: replies) 55 | 56 | self.loadMoreEnabled.value = data.totalPage > data.currentPage 57 | } 58 | } 59 | }, onError: { error in 60 | print(error) 61 | }, onCompleted: { 62 | completion?() 63 | }).disposed(by: disposeBag) 64 | } 65 | 66 | func fetchMoreData(completion: (() -> Void)? = nil) { 67 | guard currentPage < totalPage else { 68 | return 69 | } 70 | let page = currentPage + 1 71 | fetcData(page: page, completion: completion) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /V2EX/ViewModel/FavoriteViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FavoriteViewModel.swift 3 | // V2EX 4 | // 5 | // Created by darker on 2017/3/17. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import RxSwift 10 | import Moya 11 | 12 | enum FavoriteDataType: Int { 13 | case topic = 0, following = 1, node = 2 14 | } 15 | 16 | class FavoriteViewModel { 17 | let dataItems = Variable<[FavoriteItem]>([]) 18 | let loadMoreEnabled = Variable(false) 19 | 20 | private var topics: [FavoriteItem] = [] 21 | private var followings: [FavoriteItem] = [] 22 | private var nodes: [FavoriteItem] = [] 23 | 24 | private var topicCurrentPage = 1 25 | private var topicTotalPage = 1 26 | 27 | private var followCurrentPage = 1 28 | private var followTotalPage = 1 29 | 30 | private var nodeCurrentPage = 1 31 | private var nodeTotalPage = 1 32 | 33 | var type: FavoriteDataType = .topic { 34 | didSet { 35 | if type != oldValue { 36 | dataItems.value.removeAll() 37 | switch type { 38 | case .topic: 39 | if !topics.isEmpty { 40 | dataItems.value.append(contentsOf: topics) 41 | loadMoreEnabled.value = topicTotalPage > topicCurrentPage 42 | }else { 43 | fetcData() 44 | } 45 | case .following: 46 | if !followings.isEmpty { 47 | dataItems.value.append(contentsOf:followings) 48 | loadMoreEnabled.value = followTotalPage > followCurrentPage 49 | }else { 50 | fetcData() 51 | } 52 | case .node: 53 | if !nodes.isEmpty { 54 | dataItems.value.append(contentsOf: nodes) 55 | loadMoreEnabled.value = nodeTotalPage > nodeCurrentPage 56 | }else { 57 | fetcData() 58 | } 59 | } 60 | } 61 | } 62 | } 63 | 64 | private let disposeBag = DisposeBag() 65 | 66 | init() { 67 | fetcData() 68 | } 69 | 70 | func fetcData(page: Int = 1, completion: (() -> Void)? = nil) { 71 | var api: API 72 | switch type { 73 | case .node: 74 | api = API.favoriteNodes(page: page) 75 | case .topic: 76 | api = API.favoriteTopics(page: page) 77 | case .following: 78 | api = API.favoriteFollowings(page: page) 79 | } 80 | API.provider.request(api).subscribe(onNext: { response in 81 | switch self.type { 82 | case .topic: 83 | if let data = HTMLParser.shared.favoriteTopicsAndFollowings(html: response.data) { 84 | self.topicTotalPage = data.totalPage 85 | 86 | let items = data.topics.map({FavoriteItem.topicItem(item: $0)}) 87 | self.topics.append(contentsOf: items) 88 | self.dataItems.value.append(contentsOf: items) 89 | 90 | self.loadMoreEnabled.value = self.topicTotalPage > self.topicCurrentPage 91 | } 92 | case .following: 93 | if let data = HTMLParser.shared.favoriteTopicsAndFollowings(html: response.data) { 94 | self.followTotalPage = data.totalPage 95 | 96 | let items = data.topics.map({FavoriteItem.followingItem(item: $0)}) 97 | self.followings.append(contentsOf: items) 98 | self.dataItems.value.append(contentsOf: items) 99 | 100 | self.loadMoreEnabled.value = self.followTotalPage > self.followCurrentPage 101 | } 102 | case .node: 103 | if let data = HTMLParser.shared.favoriteNodes(html: response.data) { 104 | 105 | let items = data.map({FavoriteItem.nodeItem(item: $0)}) 106 | self.nodes.append(contentsOf: items) 107 | self.dataItems.value.append(contentsOf: items) 108 | 109 | self.loadMoreEnabled.value = self.nodeTotalPage > self.nodeCurrentPage 110 | } 111 | } 112 | }, onError: { error in 113 | print(error) 114 | }, onCompleted: { 115 | completion?() 116 | }).disposed(by: disposeBag) 117 | } 118 | 119 | func fetchMoreData(completion: (() -> Void)? = nil) { 120 | switch type { 121 | case .topic: 122 | guard topicCurrentPage < topicTotalPage else { 123 | return 124 | } 125 | topicCurrentPage += 1 126 | fetcData(page: topicCurrentPage, completion: completion) 127 | case .following: 128 | guard followCurrentPage < followTotalPage else { 129 | return 130 | } 131 | followCurrentPage += 1 132 | fetcData(page: followCurrentPage, completion: completion) 133 | case .node: 134 | guard nodeCurrentPage < nodeTotalPage else { 135 | return 136 | } 137 | nodeCurrentPage += 1 138 | fetcData(page: nodeCurrentPage, completion: completion) 139 | } 140 | } 141 | 142 | func removeItem(id: String) { 143 | switch type { 144 | case .topic: 145 | if let index = topics.firstIndex(where: {data -> Bool in 146 | switch data { 147 | case let .topicItem(item): 148 | return item.id == id 149 | default: 150 | return false 151 | } 152 | }) { 153 | topics.remove(at: index) 154 | } 155 | 156 | if let index = dataItems.value.firstIndex(where: {data -> Bool in 157 | switch data { 158 | case let .topicItem(item): 159 | return item.id == id 160 | default: 161 | return false 162 | } 163 | }) { 164 | dataItems.value.remove(at: index) 165 | } 166 | 167 | case .following: 168 | break 169 | case .node: 170 | break 171 | } 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /V2EX/ViewModel/HomeViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HomeViewModel.swift 3 | // V2EX 4 | // 5 | // Created by darker on 2017/3/29. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import RxSwift 10 | import Moya 11 | 12 | class HomeViewModel { 13 | let sections = Variable<[TopicListSection]>([]) 14 | let defaultNodes = Variable<[Node]>([]) 15 | var nodesNavigation: [(name: String, content: String)] = [] 16 | let loadingActivityIndicator = ActivityIndicator() 17 | 18 | var nodeHref: String = "" 19 | private let disposeBag = DisposeBag() 20 | 21 | func fetchTopics() -> Observable { 22 | return API.provider.request(API.topics(nodeHref: nodeHref)).flatMapLatest({response -> Observable in 23 | if self.defaultNodes.value.isEmpty { 24 | let nodes = HTMLParser.shared.homeNodes(html: response.data) 25 | self.defaultNodes.value = nodes 26 | } 27 | if self.nodesNavigation.isEmpty { 28 | let navigation = HTMLParser.shared.nodesNavigation(html: response.data) 29 | self.nodesNavigation = navigation 30 | } 31 | let topics = HTMLParser.shared.homeTopics(html: response.data) 32 | self.sections.value = [TopicListSection(header: "home", topics: topics)] 33 | return Observable.just(topics.isEmpty) 34 | }).share(replay: 1).observeOn(MainScheduler.instance).trackActivity(loadingActivityIndicator) 35 | } 36 | 37 | func removeTopic(for id: String) { 38 | if sections.value.isEmpty { 39 | return 40 | } 41 | if let index = sections.value[0].topics.firstIndex(where: {$0.id == id}) { 42 | sections.value[0].topics.remove(at: index) 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /V2EX/ViewModel/LoginViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoginViewModel.swift 3 | // V2EX 4 | // 5 | // Created by darker on 2017/3/3. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import RxSwift 10 | import Moya 11 | 12 | class LoginViewModel { 13 | 14 | let activityIndicator = ActivityIndicator() 15 | private var keyOnce: (usernameKey: String, passwordKey: String, codeKey: String, once: String) = ("", "", "", "") 16 | 17 | func fetchCaptchaImage() -> Observable { 18 | return API.provider.request(.once).flatMapLatest { response -> Observable in 19 | if let value = HTMLParser.shared.keyAndOnce(html: response.data) { 20 | self.keyOnce = value 21 | return API.provider.request(API.captcha(once: value.once)).flatMapLatest({ resp -> Observable in 22 | if let image = UIImage(data: resp.data) { 23 | return Observable.just(image) 24 | } 25 | return Observable.error(NetError.message(text: "获取captcha图片失败")) 26 | }) 27 | }else { 28 | return Observable.error(NetError.message(text: "获取once失败")) 29 | } 30 | } 31 | } 32 | 33 | func loginRequest(username: String, password: String, code: String) -> Observable { 34 | let api = API.login(usernameKey: keyOnce.usernameKey, passwordKey: keyOnce.passwordKey, codeKey: keyOnce.codeKey, username: username, password: password, code: code, once: keyOnce.once) 35 | return API.provider.request(api).share(replay: 1).observeOn(MainScheduler.instance).trackActivity(activityIndicator) 36 | } 37 | 38 | func twoStepVerifyLogin(code: String) -> Observable { 39 | return API.provider.request(API.twoStepVerify(code: code, once: keyOnce.once)).observeOn(MainScheduler.instance).trackActivity(activityIndicator) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /V2EX/ViewModel/MessageViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MessageViewModel.swift 3 | // V2EX 4 | // 5 | // Created by darker on 2017/3/17. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import RxSwift 10 | import Moya 11 | 12 | class MessageViewModel { 13 | let items = Variable<[Message]>([]) 14 | let loadMoreEnabled = Variable(false) 15 | 16 | private var currentPage = 1 17 | private var totalPage = 1 18 | 19 | private let disposeBag = DisposeBag() 20 | 21 | init() { 22 | fetcData() 23 | } 24 | 25 | func fetcData(page: Int = 1, completion: (() -> Void)? = nil) { 26 | API.provider.request(.notifications(page: page)).subscribe(onNext: { response in 27 | if let data = HTMLParser.shared.notifications(html: response.data) { 28 | self.currentPage = data.currentPage 29 | self.totalPage = data.totalPage 30 | 31 | self.items.value.append(contentsOf: data.messages) 32 | self.loadMoreEnabled.value = data.totalPage > data.currentPage 33 | } 34 | }, onError: { error in 35 | print(error) 36 | }, onCompleted: { 37 | completion?() 38 | }).disposed(by: disposeBag) 39 | } 40 | 41 | func fetchMoreData(completion: (() -> Void)? = nil) { 42 | guard currentPage < totalPage else { 43 | return 44 | } 45 | let page = currentPage + 1 46 | fetcData(page: page, completion: completion) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /V2EX/ViewModel/NodeTopicsViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NodeTopicsViewModel.swift 3 | // V2EX 4 | // 5 | // Created by darker on 2017/3/20. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import RxSwift 10 | import Moya 11 | 12 | class NodeTopicsViewModel { 13 | let items = Variable<[Topic]>([]) 14 | let loadMoreEnabled = Variable(false) 15 | let loadMoreCompleted = Variable(false) 16 | let loadingActivityIndicator = ActivityIndicator() 17 | let shouldLogin = Variable(false) 18 | var nodeHref: String = "" 19 | 20 | private var favoriteHref: String = "" 21 | private var currentPage = 1 22 | private var totalPage = 1 23 | private let disposeBag = DisposeBag() 24 | 25 | var isFavorited: Bool = false 26 | 27 | func fetcData(page: Int = 1) { 28 | let api = API.pageList(href: nodeHref, page: page) 29 | let observable = page > 1 ? API.provider.request(api) : API.provider.request(api).observeOn(MainScheduler.instance) 30 | .trackActivity(loadingActivityIndicator) 31 | observable.subscribe(onNext: { response in 32 | if let data = HTMLParser.shared.nodeTopics(html: response.data) { 33 | self.currentPage = data.currentPage 34 | self.totalPage = data.totalPage 35 | self.favoriteHref = data.favoriteHref 36 | self.isFavorited = data.favoriteHref.contains("/unfavorite/") 37 | 38 | if page == 1 { 39 | self.shouldLogin.value = data.shouldLogin 40 | self.items.value = data.topics 41 | }else { 42 | self.items.value.append(contentsOf: data.topics) 43 | } 44 | self.loadMoreEnabled.value = data.totalPage > data.currentPage 45 | if page > 1 { 46 | self.loadMoreCompleted.value = true 47 | } 48 | } 49 | }, onError: { error in 50 | print(error) 51 | self.loadMoreCompleted.value = true 52 | }).disposed(by: disposeBag) 53 | } 54 | 55 | func fetchMoreData() { 56 | guard currentPage < totalPage else { 57 | return 58 | } 59 | let page = currentPage + 1 60 | fetcData(page: page) 61 | } 62 | 63 | func sendFavorite(completion: ((Bool) -> Void)? = nil) { 64 | let isCancel = isFavorited 65 | let text = favoriteHref.components(separatedBy: "/node/").last 66 | let id = text?.components(separatedBy: "?").first ?? "" 67 | API.provider.request(.once).flatMap { response -> Observable in 68 | if let once = HTMLParser.shared.once(html: response.data) { 69 | return API.provider.request(.favorite(type: .node(id: id, once: once), isCancel: isCancel)) 70 | }else { 71 | return Observable.error(NetError.message(text: "获取once失败")) 72 | } 73 | }.share(replay: 1).subscribe(onNext: { response in 74 | self.isFavorited = !isCancel 75 | completion?(true) 76 | }, onError: {error in 77 | completion?(false) 78 | }).disposed(by: disposeBag) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /V2EX/ViewModel/SettingViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingViewModel.swift 3 | // V2EX 4 | // 5 | // Created by darker on 2017/3/21. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import RxSwift 10 | import Moya 11 | 12 | class SettingViewModel { 13 | var once: String = "" 14 | private let disposeBag = DisposeBag() 15 | 16 | func uploadAvatar(imageData: Data, completion: ((_ newURLString: String?) -> Void)? = nil) { 17 | API.provider.request(.once).flatMap { response -> Observable in 18 | if let once = HTMLParser.shared.once(html: response.data) { 19 | return API.provider.request(API.updateAvatar(imageData: imageData, once: once)) 20 | }else { 21 | return Observable.error(NetError.message(text: "获取once失败")) 22 | } 23 | }.subscribe(onNext: { response in 24 | if let newURLString = HTMLParser.shared.uploadAvatar(html: response.data) { 25 | completion?(newURLString) 26 | }else { 27 | completion?(nil) 28 | } 29 | }, onError: {_ in 30 | completion?(nil) 31 | }).disposed(by: disposeBag) 32 | } 33 | 34 | func fetchPrivacyStatus(completion: (() -> Void)? = nil) { 35 | API.provider.request(API.privacyOnce).subscribe(onNext: { response in 36 | if let status = HTMLParser.shared.privacyStatus(html: response.data) { 37 | self.once = status.once 38 | Account.shared.privacy = Privacy(online: status.onlineValue, topic: status.topicValue, search: status.searchValue) 39 | } 40 | completion?() 41 | }, onError: { error in 42 | completion?() 43 | }).disposed(by: disposeBag) 44 | } 45 | 46 | func setPrivacy(type: PrivacyType) { 47 | API.provider.request(API.privacy(type: type, once: once)).subscribe(onNext: { response in 48 | 49 | }, onError: {error in 50 | print("error: ", error) 51 | }).disposed(by: disposeBag) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /V2EX/ViewModel/TimelineViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TimelineViewModel.swift 3 | // V2EX 4 | // 5 | // Created by darker on 2017/3/14. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import RxSwift 10 | import Moya 11 | 12 | class TimelineViewModel { 13 | let sections = Variable<[TimelineSection]>([]) 14 | let joinTime = Variable("") 15 | let loadingActivityIndicator = ActivityIndicator() 16 | 17 | var isFollowed: Bool = false 18 | var isBlockd: Bool = false 19 | 20 | private var tValue: String = "" 21 | private var idValue: String = "" 22 | 23 | private let disposeBag = DisposeBag() 24 | 25 | 26 | func fetcTimeline(href: String) { 27 | API.provider.request(API.timeline(userHref: href)).observeOn(MainScheduler.instance) 28 | .trackActivity(loadingActivityIndicator).subscribe(onNext: { response in 29 | if let data = HTMLParser.shared.timeline(html: response.data) { 30 | self.isFollowed = data.isFollowed 31 | self.isBlockd = data.isBlockd 32 | self.tValue = data.tValue 33 | self.idValue = data.idValue 34 | self.joinTime.value = data.joinTime 35 | var topicItems = data.topics.map({SectionTimelineItem.topicItem(topic: $0)}) 36 | if !data.topicPrivacy.isEmpty { 37 | topicItems = [SectionTimelineItem.topicItem(topic: Topic())] 38 | } 39 | let replyItems = data.replys.map({SectionTimelineItem.replyItem(reply: $0)}) 40 | self.sections.value = [.topic(title: "创建的主题", privacy: data.topicPrivacy, moreHref: data.moreTopicHref, items: topicItems), 41 | .reply(title: "最近的回复", moreHref: data.moreRepliesHref, items: replyItems)] 42 | } 43 | }, onError: { error in 44 | print(error) 45 | }).disposed(by: disposeBag) 46 | } 47 | 48 | func sendFollow(completion: ((Bool) -> Void)? = nil) { 49 | let isCancel = isFollowed 50 | API.provider.request(.once).flatMap { response -> Observable in 51 | if let once = HTMLParser.shared.once(html: response.data) { 52 | return API.provider.request(.follow(id: self.idValue, once: once, isCancel: isCancel)) 53 | }else { 54 | return Observable.error(NetError.message(text: "获取once失败")) 55 | } 56 | }.share(replay: 1).subscribe(onNext: { response in 57 | self.isFollowed = !isCancel 58 | completion?(true) 59 | }, onError: {error in 60 | completion?(false) 61 | }).disposed(by: disposeBag) 62 | } 63 | 64 | func sendBlock(completion: ((Bool) -> Void)? = nil) { 65 | let isCancel = isBlockd 66 | API.provider.request(.block(id: idValue, token: tValue, isCancel: isCancel)) 67 | .subscribe(onNext: { response in 68 | self.isBlockd = !isCancel 69 | completion?(true) 70 | }, onError: {error in 71 | completion?(false) 72 | }).disposed(by: disposeBag) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /V2EX/ViewModel/TopicDetailsViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TopicDetailsViewModel.swift 3 | // V2EX 4 | // 5 | // Created by darker on 2017/3/8. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import RxSwift 10 | import Moya 11 | import Kanna 12 | 13 | class TopicDetailsViewModel { 14 | 15 | let content = Variable("") 16 | let countTime = Variable("") 17 | let sections = Variable<[TopicDetailsSection]>([]) 18 | let updateTopic = Variable(nil) 19 | let loadingActivityIndicator = ActivityIndicator() 20 | let loadMoreActivityIndicator = ActivityIndicator() 21 | 22 | var currentPage = 1 23 | 24 | private let disposeBag = DisposeBag() 25 | 26 | var topic: Topic 27 | var htmlContent: String? 28 | init(topic: Topic) { 29 | self.topic = topic 30 | fetchDetails(href: topic.href) 31 | } 32 | 33 | func insertNew(text: String, atName: String?) { 34 | var atString = "" 35 | var string = text 36 | if let atName = atName { 37 | string = text.replacingOccurrences(of: "@\(atName) ", with: "") 38 | atString = "@\(atName) " 39 | } 40 | let content = "
\(atString)\(string)
" 41 | 42 | let number = currentPage > 1 ? sections.value[1].comments.count : sections.value[0].comments.count 43 | let comment = Comment(id: "", content: content, time: "刚刚", thanks: "0", number: "\(number + 1)", user: Account.shared.user.value) 44 | 45 | if currentPage > 1 { 46 | sections.value[1].comments.append(comment) 47 | }else { 48 | sections.value[0].comments.append(comment) 49 | } 50 | } 51 | 52 | func fetchDetails(href: String) { 53 | API.provider.request(.pageList(href: href, page: 0)) 54 | .observeOn(MainScheduler.instance) 55 | .trackActivity(loadingActivityIndicator) 56 | .subscribe(onNext: { response in 57 | if let data = HTMLParser.shared.topicDetails(html: response.data) { 58 | self.topic.token = data.topic.token 59 | self.topic.isThank = data.topic.isThank 60 | self.topic.isFavorite = data.topic.isFavorite 61 | if let owner = data.topic.owner { 62 | self.topic.owner = owner 63 | } 64 | if let node = data.topic.node { 65 | self.topic.node = node 66 | } 67 | if !data.topic.title.isEmpty { 68 | self.topic.title = data.topic.title 69 | } 70 | 71 | var updateTopicInfo = self.topic 72 | updateTopicInfo.creatTime = data.topic.creatTime 73 | self.updateTopic.value = updateTopicInfo 74 | 75 | self.content.value = data.topic.content 76 | self.countTime.value = data.countTime 77 | self.currentPage = data.currentPage 78 | if data.currentPage > 1 { 79 | self.sections.value = [TopicDetailsSection(type: .more, comments: [Comment()]), TopicDetailsSection(type: .data, comments: data.comments)] 80 | }else { 81 | self.sections.value = [TopicDetailsSection(type: .data, comments: data.comments)] 82 | } 83 | 84 | self.htmlContent = HTMLParser.shared.htmlContent(html: data.topic.content) 85 | } 86 | 87 | }, onError: {error in 88 | print(error) 89 | }).disposed(by: disposeBag) 90 | } 91 | 92 | func fetchMoreComments() { 93 | guard currentPage > 1 else { 94 | return 95 | } 96 | 97 | let page = currentPage - 1 98 | API.provider.request(.pageList(href: topic.href, page: page)) 99 | .observeOn(MainScheduler.instance) 100 | .trackActivity(loadMoreActivityIndicator) 101 | .subscribe(onNext: { response in 102 | if let data = HTMLParser.shared.topicDetails(html: response.data) { 103 | 104 | self.sections.value[1].comments.insert(contentsOf: data.comments, at: 0) 105 | if data.currentPage < 2 && self.sections.value.count == 2 { 106 | self.sections.value.remove(at: 0) 107 | } 108 | self.currentPage = data.currentPage 109 | } 110 | 111 | }, onError: { error in 112 | print(error) 113 | }).disposed(by: disposeBag) 114 | } 115 | 116 | func sendComment(content: String, atName: String? = nil, completion: ((Swift.Error?) -> Void)? = nil) { 117 | API.provider.request(.once).flatMap { response -> Observable in 118 | if let once = HTMLParser.shared.once(html: response.data) { 119 | return API.provider.request(API.comment(topicHref: self.topic.href, content: content, once: once)) 120 | }else { 121 | return Observable.error(NetError.message(text: "获取once失败")) 122 | } 123 | }.share(replay: 1).subscribe(onNext: { response in 124 | self.insertNew(text: content, atName: atName) 125 | completion?(nil) 126 | }, onError: {error in 127 | completion?(error) 128 | }).disposed(by: disposeBag) 129 | } 130 | 131 | func sendThank(type: ThankType, completion: ((Bool) -> Void)? = nil) { 132 | topic.isThank = true 133 | API.provider.request(.thank(type: type, token: topic.token)).subscribe(onNext: {response in 134 | self.topic.isThank = true 135 | completion?(true) 136 | }, onError: {error in 137 | completion?(false) 138 | }).disposed(by: disposeBag) 139 | } 140 | 141 | func sendIgnore(completion: ((Bool) -> Void)? = nil) { 142 | API.provider.request(.once).flatMap { response -> Observable in 143 | if let once = HTMLParser.shared.once(html: response.data) { 144 | return API.provider.request(API.ignoreTopic(id: self.topic.id, once: once)) 145 | }else { 146 | return Observable.error(NetError.message(text: "获取once失败")) 147 | } 148 | }.share(replay: 1).subscribe(onNext: { response in 149 | completion?(true) 150 | }, onError: {error in 151 | completion?(false) 152 | }).disposed(by: disposeBag) 153 | } 154 | 155 | func sendFavorite(completion: ((Bool) -> Void)? = nil) { 156 | let isCancel = topic.isFavorite 157 | API.provider.request(.favorite(type: .topic(id: topic.id, token: topic.token), isCancel: isCancel)).subscribe(onNext: {response in 158 | self.topic.isFavorite = !isCancel 159 | completion?(true) 160 | }, onError: {error in 161 | completion?(false) 162 | }).disposed(by: disposeBag) 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /V2EX/Views/GrowingTextView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GrowingTextView.swift 3 | // Pods 4 | // 5 | // Created by Kenneth Tsang on 17/2/2016. 6 | // Copyright (c) 2016 Kenneth Tsang. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | @objc public protocol GrowingTextViewDelegate: UITextViewDelegate { 13 | @objc optional func textViewDidChangeHeight(_ textView: GrowingTextView, height: CGFloat) 14 | } 15 | 16 | @IBDesignable @objc 17 | open class GrowingTextView: UITextView { 18 | 19 | // Maximum length of text. 0 means no limit. 20 | @IBInspectable open var maxLength: Int = 0 21 | 22 | // Trim white space and newline characters when end editing. Default is true 23 | @IBInspectable open var trimWhiteSpaceWhenEndEditing: Bool = true 24 | 25 | // Maximm height of the textview 26 | @IBInspectable open var maxHeight: CGFloat = CGFloat(0) 27 | 28 | // Placeholder properties 29 | // Need to set both placeHolder and placeHolderColor in order to show placeHolder in the textview 30 | @IBInspectable open var placeHolder: NSString? { 31 | didSet { setNeedsDisplay() } 32 | } 33 | @IBInspectable open var placeHolderColor: UIColor = UIColor(white: 0.8, alpha: 1.0) { 34 | didSet { setNeedsDisplay() } 35 | } 36 | @IBInspectable open var placeHolderLeftMargin: CGFloat = 5 { 37 | didSet { setNeedsDisplay() } 38 | } 39 | 40 | override open var text: String! { 41 | didSet { 42 | setNeedsDisplay() 43 | } 44 | } 45 | 46 | fileprivate var heightConstraint: NSLayoutConstraint? 47 | 48 | // Initialize 49 | override public init(frame: CGRect, textContainer: NSTextContainer?) { 50 | super.init(frame: frame, textContainer: textContainer) 51 | commonInit() 52 | } 53 | 54 | required public init?(coder aDecoder: NSCoder) { 55 | super.init(coder: aDecoder) 56 | commonInit() 57 | } 58 | 59 | open override var intrinsicContentSize: CGSize { 60 | return CGSize(width: UIView.noIntrinsicMetric, height: 30) 61 | } 62 | 63 | func associateConstraints() { 64 | // iterate through all text view's constraints and identify 65 | // height,from: https://github.com/legranddamien/MBAutoGrowingTextView 66 | for constraint in self.constraints { 67 | if (constraint.firstAttribute == .height) { 68 | if (constraint.relation == .equal) { 69 | self.heightConstraint = constraint; 70 | } 71 | } 72 | } 73 | } 74 | 75 | // Listen to UITextView notification to handle trimming, placeholder and maximum length 76 | fileprivate func commonInit() { 77 | self.contentMode = .redraw 78 | associateConstraints() 79 | NotificationCenter.default.addObserver(self, selector: #selector(textDidChange), name: UITextView.textDidChangeNotification, object: self) 80 | NotificationCenter.default.addObserver(self, selector: #selector(textDidEndEditing), name: UITextView.textDidEndEditingNotification, object: self) 81 | } 82 | 83 | // Remove notification observer when deinit 84 | deinit { 85 | NotificationCenter.default.removeObserver(self) 86 | } 87 | 88 | // Calculate height of textview 89 | override open func layoutSubviews() { 90 | super.layoutSubviews() 91 | let size = sizeThatFits(CGSize(width:bounds.size.width, height: CGFloat.greatestFiniteMagnitude)) 92 | var height = size.height 93 | if maxHeight > 0 { 94 | height = min(size.height, maxHeight) 95 | } 96 | 97 | if (heightConstraint == nil) { 98 | heightConstraint = NSLayoutConstraint(item: self, attribute: .height, relatedBy: .equal, toItem: nil, attribute: .notAnAttribute, multiplier: 1.0, constant: height) 99 | addConstraint(heightConstraint!) 100 | } 101 | 102 | if height != heightConstraint?.constant { 103 | self.heightConstraint!.constant = height; 104 | scrollRangeToVisible(NSMakeRange(0, 0)) 105 | if let delegate = delegate as? GrowingTextViewDelegate { 106 | delegate.textViewDidChangeHeight?(self, height: height) 107 | } 108 | } 109 | } 110 | 111 | // Show placeholder 112 | override open func draw(_ rect: CGRect) { 113 | super.draw(rect) 114 | if text.isEmpty { 115 | guard let placeHolder = placeHolder else { return } 116 | let paragraphStyle = NSMutableParagraphStyle() 117 | paragraphStyle.alignment = textAlignment 118 | 119 | let rect = CGRect(x: textContainerInset.left + placeHolderLeftMargin, 120 | y: textContainerInset.top, 121 | width: frame.size.width - textContainerInset.left - textContainerInset.right, 122 | height: frame.size.height) 123 | 124 | var attributes: [NSAttributedString.Key: Any] = [ 125 | NSAttributedString.Key.foregroundColor: placeHolderColor, 126 | NSAttributedString.Key.paragraphStyle: paragraphStyle 127 | ] 128 | if let font = font { 129 | attributes[NSAttributedString.Key.font] = font 130 | } 131 | 132 | placeHolder.draw(in: rect, withAttributes: attributes) 133 | } 134 | } 135 | 136 | // Trim white space and new line characters when end editing. 137 | @objc func textDidEndEditing(notification: Notification) { 138 | if let notificationObject = notification.object as? GrowingTextView { 139 | if notificationObject === self { 140 | if trimWhiteSpaceWhenEndEditing { 141 | text = text?.trimmingCharacters(in: .whitespacesAndNewlines) 142 | setNeedsDisplay() 143 | } 144 | } 145 | } 146 | } 147 | 148 | // Limit the length of text 149 | @objc func textDidChange(notification: Notification) { 150 | if let notificationObject = notification.object as? GrowingTextView { 151 | if notificationObject === self { 152 | if maxLength > 0 && text.count > maxLength { 153 | 154 | let endIndex = text.index(text.startIndex, offsetBy: maxLength) 155 | text = String(text[.. { 54 | return Binder(self.base, binding: { (hud, animating) in 55 | if animating { 56 | HUD.show() 57 | }else { 58 | HUD.hide(animated: true) 59 | } 60 | }) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /V2EX/Views/InputCommentBar.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InputCommentBar.swift 3 | // V2EX 4 | // 5 | // Created by darker on 2017/3/27. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import RxCocoa 11 | import RxSwift 12 | 13 | class InputCommentBar: UIToolbar { 14 | fileprivate lazy var textView: GrowingTextView = GrowingTextView() 15 | fileprivate lazy var sendButton = UIButton() 16 | private let disposeBag = DisposeBag() 17 | private var isSetupSubviews = false 18 | var shouldBeginEditing: ((Bool) -> Void)? 19 | 20 | var atName: String? { 21 | willSet { 22 | if let name = newValue { 23 | textView.text = "@\(name) " 24 | } 25 | } 26 | } 27 | 28 | private var atText: String { 29 | if let name = atName { 30 | return "@\(name) " 31 | } 32 | return "" 33 | } 34 | 35 | override init(frame: CGRect) { 36 | super.init(frame: frame) 37 | 38 | translatesAutoresizingMaskIntoConstraints = false 39 | backgroundColor = AppStyle.shared.theme.barTintColor 40 | isTranslucent = false 41 | barTintColor = AppStyle.shared.theme.barTintColor 42 | } 43 | 44 | required init?(coder aDecoder: NSCoder) { 45 | super.init(coder: aDecoder) 46 | } 47 | 48 | func setup() { 49 | isSetupSubviews = true 50 | 51 | textView.translatesAutoresizingMaskIntoConstraints = false 52 | textView.maxHeight = 80 53 | textView.placeHolder = "添加回复..." 54 | textView.placeHolderColor = UIColor(white: 0.8, alpha: 1.0) 55 | textView.placeHolderLeftMargin = 5.0 56 | textView.font = UIFont.systemFont(ofSize: 15) 57 | textView.delegate = self 58 | textView.backgroundColor = AppStyle.shared.theme.tableBackgroundColor 59 | if AppStyle.shared.theme == .night { 60 | textView.textColor = #colorLiteral(red: 0.6078431373, green: 0.6862745098, blue: 0.8, alpha: 1) 61 | textView.placeHolderColor = #colorLiteral(red: 0.4196078431, green: 0.4901960784, blue: 0.5490196078, alpha: 1) 62 | textView.clipsToBounds = true 63 | textView.layer.cornerRadius = 4 64 | 65 | textView.keyboardAppearance = .dark 66 | } 67 | 68 | addSubview(textView) 69 | 70 | sendButton.translatesAutoresizingMaskIntoConstraints = false 71 | sendButton.titleLabel?.font = UIFont.boldSystemFont(ofSize: 16) 72 | sendButton.setTitleColor(#colorLiteral(red: 0.2203660309, green: 0.5916196108, blue: 0.9413970709, alpha: 1), for: .normal) 73 | sendButton.setTitleColor(#colorLiteral(red: 0.2203660309, green: 0.5916196108, blue: 0.9413970709, alpha: 1).withAlphaComponent(0.6), for: .disabled) 74 | sendButton.setTitle("发送", for: .normal) 75 | addSubview(sendButton) 76 | 77 | sendButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -5).isActive = true 78 | sendButton.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true 79 | sendButton.heightAnchor.constraint(equalToConstant: 35).isActive = true 80 | sendButton.widthAnchor.constraint(equalToConstant: 50).isActive = true 81 | 82 | textView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 4).isActive = true 83 | textView.trailingAnchor.constraint(equalTo: sendButton.leadingAnchor, constant: -5).isActive = true 84 | textView.topAnchor.constraint(equalTo: topAnchor, constant: 5).isActive = true 85 | textView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4).isActive = true 86 | 87 | textView.rx.text.orEmpty.map {[weak self] text -> Bool in 88 | let content = text.trimmingCharacters(in: .whitespacesAndNewlines) 89 | if let atText = self?.atText { 90 | return !content.isEmpty && atText != content && !atText.contains(content) 91 | } 92 | return !content.isEmpty 93 | }.share(replay: 1).bind(to: sendButton.rx.isEnabled).disposed(by: disposeBag) 94 | 95 | 96 | 97 | } 98 | 99 | func clear() { 100 | textView.text = "" 101 | } 102 | 103 | func startEditing() { 104 | textView.becomeFirstResponder() 105 | } 106 | 107 | func endEditing(isClear: Bool = false) { 108 | if textView.isFirstResponder { 109 | textView.resignFirstResponder() 110 | } 111 | if isClear { 112 | clear() 113 | } 114 | } 115 | 116 | override var isFirstResponder: Bool { 117 | return textView.isFirstResponder 118 | } 119 | 120 | override func layoutSubviews() { 121 | super.layoutSubviews() 122 | if let _ = superview, !isSetupSubviews { 123 | layoutIfNeeded() 124 | setup() 125 | } 126 | 127 | } 128 | } 129 | 130 | extension InputCommentBar: GrowingTextViewDelegate { 131 | func textViewShouldBeginEditing(_ textView: UITextView) -> Bool { 132 | shouldBeginEditing?(true) 133 | return true 134 | } 135 | 136 | func textViewShouldEndEditing(_ textView: UITextView) -> Bool { 137 | if !textView.text.isEmpty && !sendButton.isEnabled { 138 | clear() 139 | } 140 | shouldBeginEditing?(false) 141 | return true 142 | } 143 | 144 | func textViewDidChangeHeight(_ textView: GrowingTextView, height: CGFloat) { 145 | layoutIfNeeded() 146 | } 147 | 148 | func textViewDidChange(_ textView: UITextView) { 149 | if textView.text.isEmpty { 150 | atName = nil 151 | } 152 | } 153 | } 154 | 155 | extension Reactive where Base: InputCommentBar { 156 | var sendEvent: ControlEvent<(String, String?)> { 157 | let source = self.base.sendButton.rx.tap.map {_ -> (String, String?) in 158 | return (self.base.textView.text, self.base.atName) 159 | }.share(replay: 1) 160 | return ControlEvent(events: source) 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /V2EX/Views/LoginButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoginButton.swift 3 | // V2EX 4 | // 5 | // Created by darker on 2017/3/14. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import RxCocoa 11 | import RxSwift 12 | 13 | class LoginButton: UIButton { 14 | 15 | override func awakeFromNib() { 16 | super.awakeFromNib() 17 | 18 | layer.cornerRadius = 2 19 | layer.borderColor = #colorLiteral(red: 0.9019607843, green: 0.9019607843, blue: 0.9019607843, alpha: 1).cgColor 20 | layer.borderWidth = 0.5 21 | layer.masksToBounds = false 22 | layer.shadowColor = UIColor.black.cgColor 23 | layer.shadowOpacity = 0.1 24 | layer.shadowOffset = CGSize(width: 0, height: 0) 25 | layer.shadowRadius = 1 26 | } 27 | } 28 | 29 | -------------------------------------------------------------------------------- /V2EX/Views/PlaceHolderTextView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlaceHolderTextView.swift 3 | // V2EX 4 | // 5 | // Created by darker on 2017/4/11. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | @IBDesignable 12 | class PlaceHolderTextView: UITextView { 13 | 14 | @IBInspectable var placeHolder: NSString? { 15 | didSet { 16 | setNeedsDisplay() 17 | } 18 | } 19 | 20 | @IBInspectable var placeHolderColor: UIColor = UIColor(white: 0.8, alpha: 1.0) { 21 | didSet { 22 | setNeedsDisplay() 23 | } 24 | } 25 | 26 | deinit { 27 | NotificationCenter.default.removeObserver(self) 28 | } 29 | 30 | override func awakeFromNib() { 31 | super.awakeFromNib() 32 | 33 | NotificationCenter.default.addObserver(self, selector: #selector(textDidChange), name: UITextView.textDidChangeNotification, object: self) 34 | } 35 | 36 | @objc func textDidChange(notification: Notification) { 37 | if let notificationObject = notification.object as? PlaceHolderTextView { 38 | if notificationObject === self { 39 | setNeedsDisplay() 40 | } 41 | } 42 | } 43 | 44 | override open func draw(_ rect: CGRect) { 45 | super.draw(rect) 46 | if text.isEmpty { 47 | guard let placeHolder = placeHolder else { return } 48 | let paragraphStyle = NSMutableParagraphStyle() 49 | paragraphStyle.alignment = textAlignment 50 | 51 | let rect = CGRect(x: textContainerInset.left + 5.0, 52 | y: textContainerInset.top, 53 | width: frame.size.width - textContainerInset.left - textContainerInset.right, 54 | height: frame.size.height) 55 | 56 | var attributes: [NSAttributedString.Key: Any] = [ 57 | NSAttributedString.Key.foregroundColor: placeHolderColor, 58 | NSAttributedString.Key.paragraphStyle: paragraphStyle 59 | ] 60 | if let font = font { 61 | attributes[NSAttributedString.Key.font] = font 62 | } 63 | placeHolder.draw(in: rect, withAttributes: attributes) 64 | } 65 | } 66 | 67 | } 68 | -------------------------------------------------------------------------------- /V2EX/Views/ProfileHeaderView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProfileHeaderView.swift 3 | // V2EX 4 | // 5 | // Created by darker on 2017/3/14. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Kingfisher 11 | import RxCocoa 12 | import RxSwift 13 | 14 | class ProfileHeaderView: UIView, ThemeUpdating { 15 | @IBOutlet weak var avatarButton: UIButton! 16 | @IBOutlet weak var nameLabel: UILabel! 17 | 18 | var user: User? { 19 | willSet { 20 | if let model = newValue { 21 | avatarButton.setTitle("", for: .normal) 22 | avatarButton.kf.setBackgroundImage(with: URL(string: model.avatar(.large)), for: .normal) 23 | nameLabel.text = model.name 24 | nameLabel.isHidden = false 25 | } 26 | } 27 | } 28 | 29 | override func awakeFromNib() { 30 | super.awakeFromNib() 31 | 32 | avatarButton.clipsToBounds = true 33 | avatarButton.layer.cornerRadius = 40 34 | nameLabel.isHidden = true 35 | 36 | updateTheme() 37 | } 38 | 39 | func updateTheme() { 40 | backgroundColor = AppStyle.shared.theme.tableBackgroundColor 41 | nameLabel.textColor = AppStyle.shared.theme.black64Color 42 | if AppStyle.shared.theme == .night { 43 | avatarButton.backgroundColor = #colorLiteral(red: 0.1411764706, green: 0.2039215686, blue: 0.2784313725, alpha: 1) 44 | }else { 45 | avatarButton.backgroundColor = #colorLiteral(red: 0.9019607843, green: 0.9019607843, blue: 0.9019607843, alpha: 1) 46 | } 47 | avatarButton.setTitleColor(AppStyle.shared.theme.black102Color, for: .normal) 48 | } 49 | 50 | func logout() { 51 | avatarButton.setTitle("登录", for: .normal) 52 | avatarButton.setBackgroundImage(nil, for: .normal) 53 | nameLabel.isHidden = true 54 | } 55 | } 56 | 57 | extension Reactive where Base: ProfileHeaderView { 58 | var user: Binder { 59 | return Binder(self.base) { view, value in 60 | view.user = value 61 | } 62 | } 63 | 64 | var isLoginEnabled: Binder { 65 | return Binder(self.base) { view, value in 66 | view.avatarButton.isUserInteractionEnabled = value 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /V2EX/Views/TimelineHeaderView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TimelineHeaderView.swift 3 | // V2EX 4 | // 5 | // Created by darker on 2017/3/15. 6 | // Copyright © 2017年 darker. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import RxSwift 11 | import RxCocoa 12 | 13 | class TimelineHeaderView: UIView { 14 | 15 | @IBOutlet weak var textLabel: UILabel! 16 | 17 | var heightUpdate = Variable(false) 18 | 19 | var text: String? { 20 | willSet { 21 | if let content = newValue { 22 | textLabel.text = content 23 | layoutIfNeeded() 24 | let headHeight = self.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize).height 25 | var rect = self.frame 26 | rect.size.height = headHeight 27 | self.frame = rect 28 | self.heightUpdate.value = true 29 | } 30 | } 31 | } 32 | 33 | override func awakeFromNib() { 34 | super.awakeFromNib() 35 | 36 | backgroundColor = AppStyle.shared.theme.cellBackgroundColor 37 | textLabel.textColor = AppStyle.shared.theme.black64Color 38 | } 39 | } 40 | 41 | extension Reactive where Base: TimelineHeaderView { 42 | var text: Binder { 43 | return Binder(self.base) { view, content in 44 | view.text = content 45 | } 46 | } 47 | } 48 | --------------------------------------------------------------------------------