前言ios
断断续续的已经学习Swift一年多了, 从1.2到如今的2.2, 一直在语法之间徘徊, 学一段时间, 工做一忙, 再捡起来隔段时间又忘了.思来想去, 趁着这两个月加班不是特别多, 就决定用swift仿写一个完整项目.git
花田小憩:是一个植物美学生活平台,github
以天然生活为主导,web
提倡植物学生活方法,swift
倡导美学标准的生活态度的一个APP.数组
我的文字功底有限, 就我而言, 这款APP作的挺惟美的…网络
github地址app
https://github.com/SunLiner/Floral框架
声明async
此花田小憩项目里面的都是真实接口, 真实数据, 仅供学习, 毋做其余用途!!!
项目部分截图
因为项目的大致功能都已经实现了的, 因此整个项目仍是比较庞大的.因此, 下面罗列部分功能的截图.
因为gif录制的时候, 会从新渲染一遍图片, 因此致使项目中用到高斯模糊的地方, 看起来感受比较乱, 实际效果仍是不错的.
新特性
详情页
更多项目截图请点击原文查看
项目环境
编译器 : Xcode7.3及以上
语言 : Swift2.2
整个项目都是采用纯代码开发模式
tip: 以前编译环境这儿有点错误, 由于我项目中用了Swift2.2的特性, 2.2以后方法名须要写成#selector(AddAddressViewController.save), 再也不使用双引号了
第三方框架
use_frameworks!
platform :ios, "8.0"
target 'Floral' do
pod 'SnapKit', '~> 0.20.0' ## 自动布局
pod 'Alamofire', '~> 3.3.1' ## 网络请求, swift版的AFN
pod 'Kingfisher', '~> 2.3.1' ## 轻量级的SDWebImage
end
还用到了MBProgressHUD.
除此以外,几乎所有都是本身造的小轮子…
目录结构详解
Classes下包含7个功能目录:
①Resources : 项目用到的资源,包含plist文件, js文件和字体
②Network : 网络请求, 全部的网络请求都在这里面, 接口和参数都有详细的注释
③Tool : 包含tools(工具类), 3rdLib(第三方:友盟分享, MBProgressHUD ), Category(全部项目用到的分类)
④Home : 首页(专题), 包含专题分类, 详情, 每周Top10, 评论, 分享等等功能模块
⑤Main : UITabBarController, UINavigationController设置以及新特性
⑥Malls : 商城, 包含商城分类, 商品搜索, 详情, 购物车, 购买, 订单, 地址管理, 支付等等功能模块
⑦Profile : 我的中心, 专栏做者, 登陆/注册/忘记密码, 设置等功能模块
你们能够下载项目, 对照这个目录结构进行查看, 很典型的MVC文件结构, 仍是很方便的.
项目部分功能模块详解
① 新特性NewFeatureViewController : 这个功能模块仍是比较简单的, 用到了UICollectionViewController, 而后本身添加了UIPageControl, 只须要监听最后一个cell的点击便可.
这儿有一个注意点是: 咱们须要根据版本号来判断是进入新特性界面, 广告页仍是首页.
private let SLBundleShortVersionString = "SLBundleShortVersionString"
// MARK: - 判断版本号
private func toNewFeature() -> Bool
{
// 根据版本号来肯定是否进入新特性界面
let currentVersion = NSBundle.mainBundle().infoDictionary!["CFBundleShortVersionString"] as! String
let oldVersion = NSUserDefaults.standardUserDefaults().objectForKey(SLBundleShortVersionString) ?? ""
// 若是当前的版本号和本地保存的版本比较是降序, 则须要显示新特性
if (currentVersion.compare(oldVersion as! String)) == .OrderedDescending{
// 保存当前的版本
NSUserDefaults.standardUserDefaults().setObject(currentVersion, forKey: SLBundleShortVersionString)
return true
}
return false
}
② 下拉刷新RefreshControl : 在这个项目中, 没有用第三方的下拉刷新控件, 而是本身实现了一个简单的下拉刷新轮子, 而后赋值给UITableViewController的public var refreshControl: UIRefreshControl?属性. 主要原理就是判断下拉时的frame变化:
// 监听frame的变化
addObserver(self, forKeyPath: "frame", options:.New, context: nil)
// 刷新的时候, 再也不进行其余操做
private var isLoading = false
override func observeValueForKeyPath(keyPath: String?, ofObject object: AnyObject?, change: [String : AnyObject]?, context: UnsafeMutablePointer<Void>) {
let y = frame.origin.y
// 1. 最开始一进来的时候, 刷新按钮是隐藏的, y就是-64, 须要先判断掉, y>=0 , 说明刷新控件已经彻底缩回去了...
if y >= 0 || y == -64
{
return
}
// 2. 判断是否一进来就进行刷新
if beginAnimFlag && (y == -60.0 || y == -124.0){
if !isLoading {
isLoading = true
animtoringFlag = true
tipView.beginLoadingAnimator()
}
return
}
// 3. 释放已经触发了刷新事件, 若是触发了, 须要进行旋转
if refreshing && !animtoringFlag
{
animtoringFlag = true
tipView.beginLoadingAnimator()
return
}
if y <= -50 && !rotationFlag
{
rotationFlag = true
tipView.rotationRefresh(rotationFlag)
}else if(y > -50 && rotationFlag){
rotationFlag = false
tipView.rotationRefresh(rotationFlag)
}
}
③ 高斯模糊: 使用的是系统自带的高斯模糊控件UIVisualEffectView, 它是@available(iOS 8.0, *), 附一段简单的使用代码
private lazy var blurView : BlurView = {
let blur = BlurView(effect: UIBlurEffect(style: .Light))
blur.categories = self.categories
blur.delegate = self
return blur
}()
能够根据alpha = 0.5, 调整alpha来调整模糊效果, gif图中的高斯模糊效果不是很明显, 实际效果特别好.
④ 商城购物车动画:这组动画仍是比较简单的, 直接附代码, 若是有什么疑惑, 能够留言或者私信我
// MARK : - 动画相关懒加载
/// layer
private lazy var animLayer : CALayer = {
let layer = CALayer()
layer.contentsGravity = kCAGravityResizeAspectFill;
layer.bounds = CGRectMake(0, 0, 50, 50);
layer.cornerRadius = CGRectGetHeight(layer.bounds) / 2
layer.masksToBounds = true;
return layer
}()
/// 贝塞尔路径
private lazy var animPath = UIBezierPath()
/// 动画组
private lazy var groupAnim : CAAnimationGroup = {
let animation = CAKeyframeAnimation(keyPath: "position")
animation.path = self.animPath.CGPath
animation.rotationMode = kCAAnimationRotateAuto
let expandAnimation = CABasicAnimation(keyPath: "transform.scale")
expandAnimation.duration = 1
expandAnimation.fromValue = 0.5
expandAnimation.toValue = 2
expandAnimation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseIn)
let narrowAnimation = CABasicAnimation(keyPath: "transform.scale")
// 先执行上面的, 而后再开始
narrowAnimation.beginTime = 1
narrowAnimation.duration = 0.5
narrowAnimation.fromValue = 2
narrowAnimation.toValue = 0.5
narrowAnimation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseOut)
let groups = CAAnimationGroup()
groups.animations = [animation,expandAnimation,narrowAnimation]
groups.duration = 1.5
groups.removedOnCompletion = false
groups.fillMode = kCAFillModeForwards
groups.delegate = self
return groups
}()
// MARK: - 点击事件处理
private var num = 0
func gotoShopCar() {
if num >= 99 {
self.showErrorMessage("亲, 企业采购请联系咱们客服")
return
}
addtoCar.userInteractionEnabled = false
// 设置layer
// 贝塞尔弧线的起点
animLayer.position = addtoCar.center
layer.addSublayer(animLayer)
// 设置path
animPath.moveToPoint(animLayer.position)
let controlPointX = CGRectGetMaxX(addtoCar.frame) * 0.5
// 弧线, controlPoint基准点, endPoint结束点
animPath.addQuadCurveToPoint(shopCarBtn.center, controlPoint: CGPointMake(controlPointX, -frame.size.height * 5))
// 添加并开始动画
animLayer.addAnimation(groupAnim, forKey: "groups")
}
// MARK: - 动画的代理
// 动画中止的代理
override func animationDidStop(anim: CAAnimation, finished flag: Bool) {
if anim == animLayer.animationForKey("groups")!{
animLayer.removeFromSuperlayer()
animLayer.removeAllAnimations()
num += 1
shopCarBtn.num = num
let animation = CATransition()
animation.duration = 0.25
shopCarBtn.layer.addAnimation(animation, forKey: nil)
let shakeAnimation = CABasicAnimation(keyPath: "transform.translation.y")
shakeAnimation.duration = 0.25
shakeAnimation.fromValue = -5
shakeAnimation.toValue = 5
shakeAnimation.autoreverses = true
shopCarBtn.layer .addAnimation(shakeAnimation, forKey: nil)
addtoCar.userInteractionEnabled = true
}
}
⑤ 主题详情页:商城详情页的作法也是差很少的, 不过更简单一点.
关键一点在于, 详情页的展现主要依靠于H5页面. 而咱们须要根据webview的高度来肯定webviewCell的高度.个人作法是监听UIWebView的webViewDidFinishLoad, 取出webView.scrollView.contentSize.height而后给详情页发送一个通知, 让其刷新界面. 暂时没有想到更好的方法, 若是您有更好的作法, 请务必告诉我, 谢谢…
⑥ UIWebView中图片的点击
第①步: 咱们建立一个image.js文件, 代码以下:
//setImage的做用是为页面的中img元素添加onClick事件,即设置点击时调用imageClick
function setImageClick(){
var imgs = document.getElementsByTagName("img");
for (var i=0;i<imgs.length;i++){
var src = imgs[i].src;
imgs[i].setAttribute("onClick","imageClick(src)");
}
document.location = imageurls;
}
//imageClick即图片 onClick时触发的方法,document.location = url;的做用是使调用
//webView: shouldStartLoadWithRequest: navigationType:方法,在该方法中咱们真正处理图片的点击
function imageClick(imagesrc){
var url="imageClick::"+imagesrc;
document.location = url;
}
第②步:在UIWebView的代理方法webViewDidFinishLoad中, 加载JS文件, 并给图片绑定绑定点击事件
// 加载js文件
webView.stringByEvaluatingJavaScriptFromString(try! String(contentsOfURL: NSBundle.mainBundle().URLForResource("image", withExtension: "js")!, encoding: NSUTF8StringEncoding))
// 给图片绑定点击事件
webView.stringByEvaluatingJavaScriptFromString("setImageClick()")
第③步:在UIWebView的代理方法-webView:shouldStartLoadWithRequest:navigationType:中判断图片的点击
func webView(webView: UIWebView, shouldStartLoadWithRequest request: NSURLRequest, navigationType: UIWebViewNavigationType) -> Bool {
let urlstr = request.URL?.absoluteString
let components : [String] = urlstr!.componentsSeparatedByString("::")
if (components.count >= 1) {
//判断是否是图片点击
if (components[0] == "imageclick") {
parentViewController?.presentViewController(ImageBrowserViewController(urls: [NSURL(string: components.last!)!], index: NSIndexPath(forItem: 0, inSection: 0)), animated: true, completion: nil)
return false;
}
return true;
}
return true
}
⑦ 登陆/注册/忘记密码:
眼尖一点的朋友可能在上面的gif中已经发现, 花田小憩中的登陆/注册/忘记密码界面几乎是同样的, 个人作法是用一个控制器LoginViewController来表明登陆/注册/忘记密码三个功能模块, 经过两个变量isRegister和isRevPwd来判断是哪一个功能, 显示哪些界面, 咱们点击注册和忘记密码的时候, 会执行代理方法:
// MARK: - LoginHeaderViewDelegate
func loginHeaderView(loginHeaderView : LoginHeaderView, clickRevpwd pwdBtn: UIButton) {
let login = LoginViewController()
login.isRevPwd = true
navigationController?.pushViewController(login, animated: true)
}
func loginHeaderView(loginHeaderView : LoginHeaderView, clickRegister registerbtn: UIButton) {
let login = LoginViewController()
login.isRegister = true
navigationController?.pushViewController(login, animated: true)
}
⑧ 验证码的倒计时功能
/// 点击"发送验证码"按钮
func clickSafeNum(btn: UIButton) {
var seconds = 10 //倒计时时间
let queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)
let timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0,queue);
dispatch_source_set_timer(timer,dispatch_walltime(nil, 0),1 * NSEC_PER_SEC, 0); //每秒执行
dispatch_source_set_event_handler(timer) {
if(seconds<=0){ //倒计时结束,关闭
dispatch_source_cancel(timer);
dispatch_async(dispatch_get_main_queue(), {
//设置界面的按钮显示 根据本身需求设置
btn.setTitleColor(UIColor.blackColor(), forState:.Normal)
btn.setTitle("获取验证码", forState:.Normal)
btn.titleLabel?.font = defaultFont14
btn.userInteractionEnabled = true
});
}else{
dispatch_async(dispatch_get_main_queue(), {
UIView.beginAnimations(nil, context: nil)
UIView.setAnimationDuration(1)
})
dispatch_async(dispatch_get_main_queue(), {
//设置界面的按钮显示 根据本身需求设置
UIView.beginAnimations(nil, context: nil)
UIView.setAnimationDuration(1)
btn.setTitleColor(UIColor.orangeColor(), forState:.Normal)
btn.setTitle("\(seconds)秒后从新发送", forState:.Normal)
btn.titleLabel?.font = UIFont.systemFontOfSize(11)
UIView.commitAnimations()
btn.userInteractionEnabled = false
})
seconds -= 1
}
}
dispatch_resume(timer)
}
⑨ 设置模块中给咱们评分
这个功能在实际开发中特别常见:
给咱们评分
代码以下, 很简单:
UIApplication.sharedApplication().openURL(NSURL(string: "itms-apps://itunes.apple.com/WebObjects/MZStore.woa/wa/viewSoftware?id=998252000")!)
其中最后的id须要填写你本身的APP在AppStore中的id, 打开iTunes找到你本身的APP或者你想要的APP, 就能查看到id.
tip: 此功能测试的时候, 必须用真机!!!
⑩ 登陆状态.
咱们能够经过NSHTTPCookieStorage中的NSHTTPCookie来判断登陆状态.也能够自定义一个字段来保存. 根据我抓包得知, 花田小憩APP的作法是第一次登陆后保存用户名和密码(MD5加密的, 我测试过), 而后每次启动应用程序的时候, 会首前后台自动登陆, 而后在进行评论/点赞等操做的时候呢, 参数中会带上用户的id.因为涉及到花田小憩的帐号密码的一些隐私, 因此登陆/注册模块, 我就没有没有完整的写出来. 有兴趣的朋友能够私信我, 我能够把接口给你, 在此声明: 仅供学习, 毋作伤天害理之事
`tip: 我在AppDelegate.swift中给你们留了一个开关, 能够快速的进行登陆状态的切换…
⑩①: 我的/专栏中心:
这两个功能是同一个控制器, 是UICollectionViewController而不是UITableViewController
你们对UITableViewController的header应该很熟悉吧, 向上滑动的时候, 会停留在navigationBar的下面, 虽然UICollectionViewController也能够设置header, 可是在iOS9之前, 他是不能直接设置停留的.在iOS9以后, 能够一行代码设置header的停留
sectionHeadersPinToVisibleBounds = true
可是在iOS9以前, 咱们须要本身实现这个功能:
//
// LevitateHeaderFlowLayout.swift
// Floral
//
// Created by ALin on 16/5/20.
// Copyright © 2016年 ALin. All rights reserved.
// 可让header悬浮的流水布局
import UIKit
class LevitateHeaderFlowLayout: UICollectionViewFlowLayout {
override func prepareLayout() {
super.prepareLayout()
// 即便界面内容没有超过界面大小,也要竖直方向滑动
collectionView?.alwaysBounceVertical = true
// sectionHeader停留
if #available(iOS 9.0, *) {
sectionHeadersPinToVisibleBounds = true
}
}
override func layoutAttributesForElementsInRect(rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
// 1. 获取父类返回的UICollectionViewLayoutAttributes数组
var answer = super.layoutAttributesForElementsInRect(rect)!
// 2. 若是是iOS9.0以上, 直接返回父类的便可. 不用执行下面的操做了. 由于咱们直接设置sectionHeadersPinToVisibleBounds = true便可
if #available(iOS 9.0, *) {
return answer
}
// 3. 若是是iOS9.0如下的系统
// 如下代码来源:http://stackoverflow.com/questions/13511733/how-to-make-supplementary-view-float-in-uicollectionview-as-section-headers-do-i%3C/p%3E
// 目的是让collectionview的header能够像tableview的header同样, 能够停留
// 建立一个索引集.(NSIndexSet:惟一的,有序的,无符号整数的集合)
let missingSections = NSMutableIndexSet()
// 遍历, 获取当前屏幕上的全部section
for layoutAttributes in answer {
// 若是是cell类型, 就加入索引集里面
if (layoutAttributes.representedElementCategory == UICollectionElementCategory.Cell) {
missingSections.addIndex(layoutAttributes.indexPath.section)
}
}
// 遍历, 将屏幕中拥有header的section从索引集中移除
for layoutAttributes in answer {
// 若是是header, 移掉所在的数组
if (layoutAttributes.representedElementKind == UICollectionElementKindSectionHeader) {
missingSections .removeIndex(layoutAttributes.indexPath.section)
}
}
// 遍历当前屏幕没有header的索引集
missingSections.enumerateIndexesUsingBlock { (idx, _) in
// 获取section中第一个indexpath
let indexPath = NSIndexPath(forItem: 0, inSection: idx)
// 获取其UICollectionViewLayoutAttributes
let layoutAttributes = self.layoutAttributesForSupplementaryViewOfKind(UICollectionElementKindSectionHeader, atIndexPath: indexPath)
// 若是有值, 就添加到UICollectionViewLayoutAttributes数组中去
if let _ = layoutAttributes{
answer.append(layoutAttributes!)
}
}
// 遍历UICollectionViewLayoutAttributes数组, 更改header的值
for layoutAttributes in answer {
// 若是是header, 改变其参数
if (layoutAttributes.representedElementKind==UICollectionElementKindSectionHeader) {
// 获取header所在的section
let section = layoutAttributes.indexPath.section
// 获取section中cell总数
let numberOfItemsInSection = collectionView!.numberOfItemsInSection(section)
// 获取第一个item的IndexPath
let firstObjectIndexPath = NSIndexPath(forItem: 0, inSection: section)
// 获取最后一个item的IndexPath
let lastObjectIndexPath = NSIndexPath(forItem: max(0, (numberOfItemsInSection - 1)), inSection: section)
// 定义两个变量来保存第一个和最后一个item的layoutAttributes属性
var firstObjectAttrs : UICollectionViewLayoutAttributes
var lastObjectAttrs : UICollectionViewLayoutAttributes
// 若是当前section中cell有值, 直接取出来便可
if (numberOfItemsInSection > 0) {
firstObjectAttrs =
self.layoutAttributesForItemAtIndexPath(firstObjectIndexPath)!
lastObjectAttrs = self.layoutAttributesForItemAtIndexPath(lastObjectIndexPath)!
} else { // 反之, 直接取header和footer的layoutAttributes属性
firstObjectAttrs = self.layoutAttributesForSupplementaryViewOfKind(UICollectionElementKindSectionHeader, atIndexPath: firstObjectIndexPath)!
lastObjectAttrs = self.layoutAttributesForSupplementaryViewOfKind(UICollectionElementKindSectionFooter, atIndexPath: lastObjectIndexPath)!
}
// 获取当前header的高和origin
let headerHeight = CGRectGetHeight(layoutAttributes.frame)
var origin = layoutAttributes.frame.origin
origin.y = min(// 2. 要保证在即将消失的临界点跟着消失
max( // 1. 须要保证header悬停, 因此取最大值
collectionView!.contentOffset.y + collectionView!.contentInset.top,
(CGRectGetMinY(firstObjectAttrs.frame) - headerHeight)
),
(CGRectGetMaxY(lastObjectAttrs.frame) - headerHeight)
)
// 默认的层次关系是0. 这儿设置大于0便可.为何设置成1024呢?由于咱们是程序猿...
layoutAttributes.zIndex = 1024
layoutAttributes.frame = CGRect(origin: origin, size: layoutAttributes.frame.size)
}
}
return answer;
}
override func shouldInvalidateLayoutForBoundsChange(newBounds: CGRect) -> Bool {
// 返回true, 表示一旦进行滑动, 就实时调用上面的-layoutAttributesForElementsInRect:方法
return true
}
}
⑩+@end:
整个项目, 东西仍是蛮多的, 也不是仅仅几百上千字能说清楚的, 几乎每个页面, 每个文件, 我都有详细的中文注释. 但愿你们一块儿进步. 这也是个人第一个开源的完整的Swift 项目, 有什么不足或者错误的地方, 但愿你们指出来, 万分感激!!!
下载地址
https://github.com/SunLiner/Floral
若是对您有些许帮助, 请☆star
后续
可能有些功能模块存在bug, 后续我都会一一进行修复和完善的, 并更新在github上.
若是您有任何疑问,或者发现bug以及不足的地方, 能够在下面给我留言, 或者关注个人新浪微博, 给我私信.
联系我
https://github.com/SunLiner